tumwater 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-run.js +2 -1
  4. package/dist/src/agent/claude/claude-account.js +6 -20
  5. package/dist/src/agent/codex/codex-account.js +5 -6
  6. package/dist/src/agent/codex/codex-args.js +5 -5
  7. package/dist/src/agent/codex/codex-rollout.js +1 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +24 -22
  9. package/dist/src/agent/opencode/opencode-catalog.js +35 -9
  10. package/dist/src/agent/session-ledger.js +22 -26
  11. package/dist/src/agent/subscription-account.js +2 -83
  12. package/dist/src/backlog/backlog-archive.js +3 -4
  13. package/dist/src/backlog/backlog-eligibility.js +14 -36
  14. package/dist/src/backlog/backlog-question-answer.js +1 -1
  15. package/dist/src/backlog/backlog.js +2 -1
  16. package/dist/src/baseline/main-guardrails.js +2 -2
  17. package/dist/src/baseline/main-watch.js +84 -50
  18. package/dist/src/brief/brief.js +26 -13
  19. package/dist/src/budget/budget.js +4 -5
  20. package/dist/src/build/build-check-report.js +10 -0
  21. package/dist/src/build/build-check-scoped.js +0 -2
  22. package/dist/src/build/build-stage.js +6 -4
  23. package/dist/src/change/change-data.js +1 -4
  24. package/dist/src/cli/cli-completion-command.js +75 -0
  25. package/dist/src/cli/cli-doctor-command.js +103 -0
  26. package/dist/src/cli/cli-init.js +2 -2
  27. package/dist/src/cli/cli-query-commands.js +1 -1
  28. package/dist/src/cli/cli-role-command.js +2 -41
  29. package/dist/src/cli/cli-start-summary.js +4 -3
  30. package/dist/src/cli/cli-start-supervisor.js +190 -0
  31. package/dist/src/cli/cli-start.js +4 -187
  32. package/dist/src/cli/completion.js +3 -3
  33. package/dist/src/cli/follow-view.js +20 -2
  34. package/dist/src/cli/help.js +2 -7
  35. package/dist/src/cli.js +19 -171
  36. package/dist/src/collections.js +11 -14
  37. package/dist/src/concurrency/lock.js +1 -1
  38. package/dist/src/config/config-keys.js +61 -20
  39. package/dist/src/config/config-request.js +11 -1
  40. package/dist/src/config/config-schema.js +1 -1
  41. package/dist/src/config/config-validation-roles.js +6 -22
  42. package/dist/src/config/config-validation.js +0 -1
  43. package/dist/src/config/config-views.js +17 -16
  44. package/dist/src/config/config-write.js +122 -79
  45. package/dist/src/config/config.js +1 -9
  46. package/dist/src/doctor/doctor-backlog-plans.js +29 -96
  47. package/dist/src/doctor/doctor-backlog.js +10 -10
  48. package/dist/src/doctor/doctor-checks.js +4 -4
  49. package/dist/src/doctor/doctor-claude-checks.js +27 -9
  50. package/dist/src/doctor/doctor-contention-checks.js +13 -5
  51. package/dist/src/doctor/doctor-fallback-checks.js +46 -24
  52. package/dist/src/doctor/doctor-holds.js +2 -2
  53. package/dist/src/doctor/doctor-orphans.js +3 -2
  54. package/dist/src/doctor/doctor-project-checks.js +21 -8
  55. package/dist/src/events/event-format.js +15 -11
  56. package/dist/src/events/event-legacy.js +2 -2
  57. package/dist/src/events/event-notify.js +4 -4
  58. package/dist/src/events/event-window.js +1 -1
  59. package/dist/src/events/events.js +8 -0
  60. package/dist/src/failure/failure-cluster.js +4 -13
  61. package/dist/src/failure/failure-data.js +4 -4
  62. package/dist/src/failure/failure-render.js +2 -2
  63. package/dist/src/failure/failure-state-change.js +50 -86
  64. package/dist/src/gates/gate-polls.js +5 -6
  65. package/dist/src/gates/startup-gates.js +2 -1
  66. package/dist/src/git/git-diff.js +6 -2
  67. package/dist/src/gui/gui-args.js +12 -0
  68. package/dist/src/gui/gui-config-commands.js +2 -64
  69. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  70. package/dist/src/gui/gui-endpoint-common.js +19 -1
  71. package/dist/src/gui/gui-operator-commands.js +21 -25
  72. package/dist/src/gui/gui-prompt-commands.js +2 -2
  73. package/dist/src/gui/gui-server.js +1 -2
  74. package/dist/src/gui/gui-team-commands.js +2 -1
  75. package/dist/src/history/history.js +4 -4
  76. package/dist/src/merge/merge-check-failures.js +12 -9
  77. package/dist/src/merge/merge-core.js +6 -31
  78. package/dist/src/merge/merge-drain.js +3 -2
  79. package/dist/src/merge/merge-post-review-drain.js +1 -1
  80. package/dist/src/merge/merge-queue-render.js +3 -3
  81. package/dist/src/merge/merge-stack.js +17 -18
  82. package/dist/src/merge/merge-to-main.js +13 -58
  83. package/dist/src/merge/merge-verify.js +4 -3
  84. package/dist/src/operator/operator-commands.js +4 -3
  85. package/dist/src/orchestrator/orchestrator-poll.js +18 -38
  86. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  87. package/dist/src/orchestrator/scheduling-scope.js +17 -26
  88. package/dist/src/orchestrator/scheduling-skip.js +9 -13
  89. package/dist/src/paths.js +2 -2
  90. package/dist/src/pi/pi-event-line.js +27 -0
  91. package/dist/src/pi/pi-listed-models.js +13 -0
  92. package/dist/src/pi/pi-stream.js +9 -2
  93. package/dist/src/pi/pi-watchdogs.js +31 -10
  94. package/dist/src/process/process-group.js +2 -21
  95. package/dist/src/process/process.js +12 -4
  96. package/dist/src/process/run-marker.js +2 -2
  97. package/dist/src/prompt/gate-prompts.js +1 -44
  98. package/dist/src/redeploy/redeployer.js +21 -1
  99. package/dist/src/review/diff-rules.js +35 -21
  100. package/dist/src/review/post-merge-review.js +47 -40
  101. package/dist/src/review/review-precheck.js +3 -3
  102. package/dist/src/review/review.js +13 -22
  103. package/dist/src/roles/legacy-role-migration.js +6 -3
  104. package/dist/src/roles/role-render.js +6 -32
  105. package/dist/src/roles/role-view.js +2 -13
  106. package/dist/src/run/ci-parity.js +19 -8
  107. package/dist/src/run/run-apply.js +0 -9
  108. package/dist/src/run/run-stage.js +6 -6
  109. package/dist/src/run/run-verdict.js +13 -4
  110. package/dist/src/run/stage-check.js +17 -24
  111. package/dist/src/runner/runner-leftover.js +9 -26
  112. package/dist/src/runner/runner-outcome.js +1 -1
  113. package/dist/src/runner/runner-pi.js +6 -12
  114. package/dist/src/runner/runner-recovery.js +4 -12
  115. package/dist/src/runner/runner-revision.js +1 -2
  116. package/dist/src/runner/runner-run.js +7 -16
  117. package/dist/src/runner/runner-state-load.js +6 -19
  118. package/dist/src/runner/runner-state.js +0 -14
  119. package/dist/src/scheduling/backoff.js +8 -3
  120. package/dist/src/scheduling/claim-entries.js +2 -1
  121. package/dist/src/status/status-budget.js +4 -43
  122. package/dist/src/status/status-data.js +34 -38
  123. package/dist/src/status/status-roles.js +1 -2
  124. package/dist/src/team/team-hold.js +7 -5
  125. package/dist/src/team/team-orchestrator-info.js +53 -21
  126. package/dist/src/team/team-polls.js +36 -86
  127. package/dist/src/text/datetime.js +16 -7
  128. package/dist/src/text/phrases.js +9 -0
  129. package/dist/src/text/suggest.js +1 -1
  130. package/dist/src/text/text.js +3 -2
  131. package/dist/src/ui/alert-feeds.js +0 -41
  132. package/dist/src/ui/badges.js +14 -75
  133. package/dist/src/ui/gui/gui-client-boot.js +0 -6
  134. package/dist/src/ui/gui/gui-client-drawer-config.js +1 -16
  135. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  136. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  137. package/dist/src/ui/gui/gui-client-team.js +8 -2
  138. package/dist/src/ui/progress-data.js +54 -4
  139. package/dist/src/ui/run-progress-model.js +12 -3
  140. package/dist/src/ui/status-payload.js +10 -25
  141. package/dist/src/ui/status-render.js +10 -17
  142. package/dist/src/ui/team-alert-backlog.js +15 -205
  143. package/dist/src/ui/team-alert-shared.js +6 -29
  144. package/dist/src/ui/team-alerts.js +4 -10
  145. package/dist/src/ui/tui/tui-backlog.js +2 -2
  146. package/dist/src/ui/tui/tui-editors.js +5 -4
  147. package/dist/src/ui/tui/tui-input.js +8 -6
  148. package/dist/src/ui/tui/tui-pane-activity.js +2 -1
  149. package/dist/src/ui/tui/tui-pane-health.js +21 -23
  150. package/dist/src/ui/tui/tui-pane-history.js +7 -5
  151. package/dist/src/ui/tui/tui-pane-pending.js +19 -15
  152. package/dist/src/ui/tui/tui-pane-session.js +4 -3
  153. package/dist/src/ui/tui/tui-panes.js +9 -1
  154. package/dist/src/ui/tui/tui.js +3 -5
  155. package/dist/src/verdict/fix-claim.js +36 -7
  156. package/dist/src/verdict/reply-contract.js +3 -4
  157. package/package.json +2 -2
  158. package/dist/src/review/review-followup.js +0 -87
  159. package/dist/src/review/review-run.js +0 -395
  160. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  161. /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
  162. /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
  163. /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
  164. /package/dist/src/cli/{question-commands.js → cli-question-commands.js} +0 -0
@@ -24,44 +24,55 @@ import { deferredReasonText, untilClockSuffix, gigabytes, windowSuffix, windowRe
24
24
  * selfcheck role's load-bearing rule asks it to
25
25
  * judge — whether the harness's RESPONSE to a failure was wrong (plans/telemetry-role.md) —
26
26
  * so they sit beside the outcomes rather than being dropped. */
27
- export const STATE_CHANGE_TYPES = new Set([
28
- "budget_paused",
29
- "budget_fallback",
30
- "budget_resumed",
31
- "team_paused",
32
- "team_resumed",
33
- "role_paused",
34
- "role_resumed",
35
- "role_streak_paused",
36
- "role_cap_paused",
37
- "role_cap_resumed",
38
- "rate_limit_hold",
39
- "rate_limit_resumed",
40
- "max_concurrent_changed",
41
- "retention_changed",
42
- "config_changed",
43
- "build_stale",
44
- "restart_pending",
45
- "restart",
46
- "restart_refused",
47
- "restart_blocked",
48
- "run_deferred",
49
- "orchestrator_start",
50
- "orchestrator_stop",
51
- "supervisor_exit",
52
- "disk_low",
53
- "disk_ok",
54
- "quiet_hours_started",
55
- "quiet_hours_ended",
56
- "maintenance_quota_hold",
57
- "maintenance_quota_resumed",
58
- "subscription_window_limited",
59
- "subscription_window_reset",
60
- "model_changed",
61
- "bootstrap_complete",
62
- "fallback_switched",
63
- "budget_handback",
64
- ]);
27
+ const STATE_CHANGE_FAMILY = {
28
+ // Budget and window reopening.
29
+ budget_paused: budgetStateChange,
30
+ budget_fallback: budgetStateChange,
31
+ budget_resumed: budgetStateChange,
32
+ // Pauses and resumes of teams, roles and caps.
33
+ team_paused: pauseStateChange,
34
+ team_resumed: pauseStateChange,
35
+ role_paused: pauseStateChange,
36
+ role_resumed: pauseStateChange,
37
+ role_streak_paused: pauseStateChange,
38
+ role_cap_paused: pauseStateChange,
39
+ role_cap_resumed: pauseStateChange,
40
+ // Model and fallback wiring.
41
+ model_changed: modelStateChange,
42
+ fallback_switched: modelStateChange,
43
+ budget_handback: modelStateChange,
44
+ // Provider hold and resume.
45
+ rate_limit_hold: providerHoldStateChange,
46
+ rate_limit_resumed: providerHoldStateChange,
47
+ // Live-config edits.
48
+ max_concurrent_changed: liveConfigStateChange,
49
+ retention_changed: liveConfigStateChange,
50
+ config_changed: liveConfigStateChange,
51
+ // Self-hosted redeploys.
52
+ build_stale: updateStateChange,
53
+ restart_pending: updateStateChange,
54
+ restart: updateStateChange,
55
+ restart_refused: updateStateChange,
56
+ restart_blocked: updateStateChange,
57
+ // Need-based deferrals and gate holds/lifts.
58
+ run_deferred: waitStateChange,
59
+ disk_low: waitStateChange,
60
+ disk_ok: waitStateChange,
61
+ quiet_hours_started: waitStateChange,
62
+ quiet_hours_ended: waitStateChange,
63
+ maintenance_quota_hold: waitStateChange,
64
+ maintenance_quota_resumed: waitStateChange,
65
+ subscription_window_limited: waitStateChange,
66
+ subscription_window_reset: waitStateChange,
67
+ // Orchestrator lifecycle.
68
+ orchestrator_start: lifecycleStateChange,
69
+ orchestrator_stop: lifecycleStateChange,
70
+ supervisor_exit: lifecycleStateChange,
71
+ bootstrap_complete: lifecycleStateChange,
72
+ };
73
+ /** The event types the digest replays, derived from STATE_CHANGE_FAMILY's keys so the filter
74
+ * set can never drift from the dispatch. */
75
+ export const STATE_CHANGE_TYPES = new Set(Object.keys(STATE_CHANGE_FAMILY));
65
76
  /** The event types the digest deliberately does not replay, one per EVENT_TYPE_LABELS key that
66
77
  * is not a STATE_CHANGE_TYPES member. Together with STATE_CHANGE_TYPES it partitions
67
78
  * EVENT_TYPE_LABELS' keys, so a new event type must be classified in one set or the other — the
@@ -416,52 +427,5 @@ function lifecycleStateChange(ev) {
416
427
  * read as complete or stand mid-word where a key or value should be; the render adds the
417
428
  * timestamp and a roleCell-sliced role, so no unbounded field reaches the page. */
418
429
  export function describeStateChange(ev) {
419
- switch (ev.type) {
420
- case "budget_paused":
421
- case "budget_fallback":
422
- case "budget_resumed":
423
- return truncateExample(budgetStateChange(ev), STATE_CHANGE_MAX);
424
- case "team_paused":
425
- case "team_resumed":
426
- case "role_paused":
427
- case "role_resumed":
428
- case "role_streak_paused":
429
- case "role_cap_paused":
430
- case "role_cap_resumed":
431
- return truncateExample(pauseStateChange(ev), STATE_CHANGE_MAX);
432
- case "model_changed":
433
- case "fallback_switched":
434
- case "budget_handback":
435
- return truncateExample(modelStateChange(ev), STATE_CHANGE_MAX);
436
- case "rate_limit_hold":
437
- case "rate_limit_resumed":
438
- return truncateExample(providerHoldStateChange(ev), STATE_CHANGE_MAX);
439
- case "max_concurrent_changed":
440
- case "retention_changed":
441
- case "config_changed":
442
- return truncateExample(liveConfigStateChange(ev), STATE_CHANGE_MAX);
443
- case "build_stale":
444
- case "restart_pending":
445
- case "restart":
446
- case "restart_refused":
447
- case "restart_blocked":
448
- return truncateExample(updateStateChange(ev), STATE_CHANGE_MAX);
449
- case "run_deferred":
450
- case "disk_low":
451
- case "disk_ok":
452
- case "quiet_hours_started":
453
- case "quiet_hours_ended":
454
- case "maintenance_quota_hold":
455
- case "maintenance_quota_resumed":
456
- case "subscription_window_limited":
457
- case "subscription_window_reset":
458
- return truncateExample(waitStateChange(ev), STATE_CHANGE_MAX);
459
- case "orchestrator_start":
460
- case "orchestrator_stop":
461
- case "supervisor_exit":
462
- case "bootstrap_complete":
463
- return truncateExample(lifecycleStateChange(ev), STATE_CHANGE_MAX);
464
- default:
465
- return truncateExample(ev.type, STATE_CHANGE_MAX);
466
- }
430
+ return truncateExample(STATE_CHANGE_FAMILY[ev.type]?.(ev) ?? ev.type, STATE_CHANGE_MAX);
467
431
  }
@@ -236,13 +236,12 @@ function pollTeamFailureAlarms(states, ctx) {
236
236
  // Team-wide failure hold, per provider (src/team/team-hold.ts, PLANS.md 2026-10-05): once two
237
237
  // roles' runs have ended on the SAME provider AND failure kind within a short window — 429s, or a
238
238
  // connection, timeout, 5xx, or model-load backend failure — roles on THAT provider start no
239
- // new runs, and the merge queue starts no new verify, whose reviewer run has no retry and would
240
- // spend a review strike on the storm — until the hold re-opens at its own deadline (Retry-After
239
+ // new runs until the hold re-opens at its own deadline (Retry-After
241
240
  // honored on the rate-limit kind, doubling on a relapse, capped). Roles on a healthy provider
242
- // keep running; a hold on the reviewer's provider (with the review gate on) still blocks every
243
- // role a pre-merge review gates — a `review.afterMerge` role's changes merge without a reviewer,
244
- // so it stays running; the orchestrator's scheduling and start passes derive that verdict from
245
- // this map. The director's runs are exempt, as under the budget gate and the
241
+ // keep running; a hold on the reviewer's provider parks only the no-role post-merge review
242
+ // drain — it holds no role start, verify or landing (no role waits on a reviewer), so the
243
+ // scheduling and start passes read no reviewer verdict from this map. The director's runs are
244
+ // exempt, as under the budget gate and the
246
245
  // operator pause: an explicit human prompt outranks an autonomous gate, one director run is not
247
246
  // the concurrency that sustains a storm, its 429 runs keep the per-run transient retry
248
247
  // (backend-failure kinds ride this hold alone), and a prompt its run fails to fulfill goes back
@@ -10,6 +10,7 @@ import { owningTeamRoot } from "../paths.js";
10
10
  import { backendMissingMessage, findBackendBinary, resolveBackendBin } from "../pi/pi-bin.js";
11
11
  import { seedClaudeAccount } from "../agent/claude/claude-account.js";
12
12
  import { codexApiKeyRefusalMessage, seedCodexAccount } from "../agent/codex/codex-account.js";
13
+ import { distinctOrdered } from "../collections.js";
13
14
  import { opencodeCatalogStale, readOpencodeCache, refreshOpencodeCatalog, } from "../agent/opencode/opencode-catalog.js";
14
15
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
15
16
  import { HOUR_MS } from "../text/datetime.js";
@@ -194,7 +195,7 @@ export function opencodeProviders(config) {
194
195
  * One list the startup gate and doctor's binary check share, so a route the harness would
195
196
  * spawn is checked on both surfaces. */
196
197
  export function backendsInUse(config) {
197
- return [...new Set(seamsInUse(config).map((s) => s.agent ?? "pi"))];
198
+ return distinctOrdered(seamsInUse(config).map((s) => s.agent ?? "pi"));
198
199
  }
199
200
  /** The first backend an enabled seam routes to that this build has no registered adapter for,
200
201
  * or undefined when every seam's backend is registered. The built-in pi and claude adapters
@@ -5,6 +5,10 @@
5
5
  import { changeBaseRev, gitLines } from "./git.js";
6
6
  import { git, gitTry } from "./git-run.js";
7
7
  import { cutSplitsSurrogatePair } from "../text/text.js";
8
+ /** The one cap on a fetched change diff, shared by the repo-relative patch (aheadOfMainDiff,
9
+ * revisionInterdiff) and the view half's uncommitted patch (change-data.ts) so neither side
10
+ * of a reviewed change can print unbounded text and one bound is all they agree on. */
11
+ export const DIFF_MAX_BYTES = 200_000;
8
12
  /** Git's C-quoting short escapes (its `sq_lookup` table) and the byte each decodes to. The
9
13
  * mapping was a nine-case switch that repeated `bytes += …; break;` per entry; keeping it as
10
14
  * data lets the decoder below handle the octal and literal fallbacks in one place. */
@@ -153,7 +157,7 @@ function splitDiffByFile(diff) {
153
157
  * see, and a binary file's short "Binary files differ" stub naturally ranks below any real text
154
158
  * change. A failed `git diff` throws: the reviewer handed an empty diff judged no change at
155
159
  * all. A view catches it itself. */
156
- export async function aheadOfMainDiff(wt, mainBranch, maxBytes = 200_000, pathspec = []) {
160
+ export async function aheadOfMainDiff(wt, mainBranch, maxBytes = DIFF_MAX_BYTES, pathspec = []) {
157
161
  const range = `${mainBranch}...HEAD`;
158
162
  const full = await git(wt, "diff", range, ...pathspec);
159
163
  if (full.length <= maxBytes)
@@ -181,7 +185,7 @@ export async function aheadOfMainDiff(wt, mainBranch, maxBytes = 200_000, pathsp
181
185
  * the two versions even when the change is tiny: at the default 60% a two-line addition can
182
186
  * fail to pair and render as two unrelated commits (`<`/`>`), which is exactly the case a
183
187
  * small revision is. */
184
- export async function revisionInterdiff(wt, mainBranch, priorSha, head, maxBytes = 200_000) {
188
+ export async function revisionInterdiff(wt, mainBranch, priorSha, head, maxBytes = DIFF_MAX_BYTES) {
185
189
  try {
186
190
  const priorBase = await changeBaseRev(wt, mainBranch, priorSha);
187
191
  const headBase = await changeBaseRev(wt, mainBranch, head);
@@ -80,6 +80,18 @@ export function rejectBadQueueFileName(res, name) {
80
80
  badRequest(res, problem);
81
81
  return true;
82
82
  }
83
+ /** Pull a required `sha` commit id out of a POST body by the rule the History revert, the
84
+ * merge-queue cancel/top, and the post-review accept endpoints share: a value that is not a
85
+ * 4-64 hex-digit commit id (so never a flag or a ref name) is refused as a 400 naming the
86
+ * value. Returns the validated commit id, or null once the 400 is sent — the body-sha twin of
87
+ * requireStringField / requirePausedFlag, so the four operators' validation and 400 wording
88
+ * cannot drift. */
89
+ export function requireSha(res, sha) {
90
+ if (typeof sha === "string" && /^[0-9a-f]{4,64}$/i.test(sha))
91
+ return sha;
92
+ badRequest(res, `"sha" must be a hex commit id of 4-64 digits${gotSuffix(sha)}`);
93
+ return null;
94
+ }
83
95
  /** Read an integer query parameter with the GUI's established discipline for an explicit
84
96
  * count: absent → `fallback` (or, when no fallback is given, the parameter is required and
85
97
  * its absence is its own 400 — "<name> required"); present but not a plain decimal integer
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The dashboard's POST config/budget command handlers (barrelled into
3
3
  * gui/gui-endpoint-commands.ts, which routes to them from src/gui/gui-server.ts):
4
- * config-set, config-unset, budget, role-cap, role-review-after-merge, role-enable,
4
+ * config-set, config-unset, budget, role-cap, role-enable,
5
5
  * role-disable, and
6
6
  * notify-test — the
7
7
  * post-handler layer onto
@@ -9,9 +9,8 @@
9
9
  * own `res`.
10
10
  */
11
11
  import { setConfigKey, setDailyBudgetUsd, setRoleModelFields, unsetConfigKey } from "../config/config-write.js";
12
- import { loadConfigSafe } from "../config/config.js";
13
12
  import { checkDailyBudgetUsd, checkMinRunInterval } from "../config/config-keys.js";
14
- import { testNotifyHook } from "../cli/notify-command.js";
13
+ import { testNotifyHook } from "../cli/cli-notify-command.js";
15
14
  import { rejectBadRole, requireConfigKey, requireRoleField, requireStringField } from "./gui-args.js";
16
15
  import { baseRoleOf } from "../roles/runner-ids.js";
17
16
  import { DIRECTOR_ROLE } from "../roles/roles.js";
@@ -88,67 +87,6 @@ export async function handleRoleCap(req, res, root) {
88
87
  return configKeyError(res, "maxDailyCostUsdPerRole." + baseRole, result);
89
88
  sendJson(res, 200, { ok: true, role: baseRole, maxDailyCostUsd: value, ...unchangedFlag(result) });
90
89
  }
91
- /** Handle POST /api/role-review-after-merge: the role drawer's Review row button moves one role
92
- * in or out of review.afterMerge — the browser twin of `tumwater role <id>
93
- * --review-after-merge`/`--clear-review-after-merge` (the CLI's just-landed write, the amber
94
- * role-cap alert's remedy on the surface the alert appears on). Same body discipline as
95
- * /api/role-cap (readPostBody → 400 malformed/non-object, 413 oversized); the body carries the
96
- * desired membership `afterMerge: true|false` (400 `afterMerge must be true or false`
97
- * otherwise), and the role id maps to its base role's membership (baseRoleOf) exactly as the
98
- * CLI's `--review-after-merge` branch does. An unknown id is refused up front (rejectBadRole
99
- * on the base id, the mirror of the CLI's requireKnownRole) because a no-op or clear write
100
- * skips validateConfig's known-role refusal — setConfigKey's section branch returns unchanged
101
- * and writes nothing when the candidate equals the resolved list, so without it a bogus id
102
- * would answer 200 on a clear. The whole-list value rides the same setConfigKey core `config
103
- * set review.afterMerge <json>` uses, so validateConfig owns the string-array shape and the
104
- * `review.afterMerge must not be "director"` refusal — no explicit director branch, matching
105
- * the CLI. A no-op write (already in the asked-for state) answers unchanged and leaves
106
- * tumwater.json byte-identical. */
107
- export async function handleRoleReviewAfterMerge(req, res, root) {
108
- const body = await readPostBody(req, res, '{"role": "feature", "afterMerge": true}');
109
- if (!body)
110
- return;
111
- const role = requireRoleField(res, body);
112
- if (role === null)
113
- return;
114
- const want = body.afterMerge;
115
- if (typeof want !== "boolean") {
116
- badRequest(res, "afterMerge must be true or false");
117
- return;
118
- }
119
- const base = baseRoleOf(role);
120
- // An unknown base id is refused up front — the mirror of the CLI's requireKnownRole. A
121
- // no-op or clear write returns the list unchanged and setConfigKey skips validateConfig
122
- // (the known-role check a real write gets for free), so without this guard a bogus id
123
- // would answer 200 on the clear path (same reason handleRoleQuietHours's clear arm carries
124
- // rejectBadRole). The stored list holds base ids, so the base id is screened — as
125
- // validateConfig's checkKnownRoleId screens the written list — letting an instance id
126
- // like feature-2 map to its known base and pass.
127
- if (rejectBadRole(root, res, base))
128
- return;
129
- // The CLI's own computation, list-for-list: the whole value lands through the same
130
- // setConfigKey core so validateConfig owns the string-array shape and the director
131
- // refusal, and the write is atomic.
132
- const list = loadConfigSafe(root).config?.review.afterMerge ?? [];
133
- const has = list.includes(base);
134
- const next = has === want ? list : want ? [...list, base] : list.filter((r) => r !== base);
135
- const result = setConfigKey(root, "review.afterMerge", JSON.stringify(next));
136
- if (!result.ok)
137
- return configKeyError(res, "review.afterMerge", result);
138
- sendJson(res, 200, {
139
- ok: true,
140
- role: base,
141
- afterMerge: want,
142
- message: result.unchanged
143
- ? want
144
- ? `${base}: already in review.afterMerge, so tumwater.json was not written`
145
- : `${base}: not in review.afterMerge, so nothing was cleared`
146
- : want
147
- ? `${base}: added to review.afterMerge`
148
- : `${base}: removed from review.afterMerge`,
149
- ...unchangedFlag(result),
150
- });
151
- }
152
90
  /** Handle POST /api/role-interval: the role drawer's min run interval editor sets or clears
153
91
  * one role's override of the team-wide minRunIntervalSeconds — the knob that makes one role
154
92
  * act rarely, distinct from the team-wide value. Same body discipline as /api/role-cap
@@ -15,7 +15,7 @@
15
15
  */
16
16
  export { handlePrompt, handlePromptRole, handlePromptCancel, handlePromptEdit, handlePromptTop } from "./gui-prompt-commands.js";
17
17
  export { handleQuestionAnswer, handleBacklogFile, handleBacklogCancel, handleBacklogRelease, handleBacklogTop, handleBacklogEdit } from "./gui-backlog-commands.js";
18
- export { handleConfigSet, handleConfigUnset, handleBudget, handleRoleCap, handleRoleReviewAfterMerge, handleRoleEnable, handleRoleDisable, handleRoleInstances, handleRoleInterval, handleRoleInstructions, handleRoleModel, handleRoleQuietHours, handleNotifyTest } from "./gui-config-commands.js";
18
+ export { handleConfigSet, handleConfigUnset, handleBudget, handleRoleCap, handleRoleEnable, handleRoleDisable, handleRoleInstances, handleRoleInterval, handleRoleInstructions, handleRoleModel, handleRoleQuietHours, handleNotifyTest } from "./gui-config-commands.js";
19
19
  export { handlePause, handlePauseRole, handleWake, handleWakeCancel } from "./gui-team-commands.js";
20
20
  export { handleResetCounters, handleRestart, handleReclaim, handleKillOrphans, handleDropBrokenPins, handleCommitBacklog, handleResyncInstall, handleRetireLeftovers, handleArchiveBacklog, handleAbort, handleAbortAll, handleStopSchedule, handleRoleNotes, handleRetire, handleRevert, handleQueueCancel, handleQueueTop, handlePostReviewAccept, handleBriefEdit } from "./gui-operator-commands.js";
21
21
  export { handleQuarantineRelease } from "./gui-quarantine-commands.js";
@@ -7,7 +7,7 @@
7
7
  * inputs with — and sendRequestResult, the shared boolean-claiming marker reply. The
8
8
  * per-domain `checked*` validators live with their domain's module, not here.
9
9
  */
10
- import { rejectBadRole } from "./gui-args.js";
10
+ import { rejectBadRole, requireSha } from "./gui-args.js";
11
11
  import { readJsonObject, sendJson } from "./gui-http-body.js";
12
12
  /** Every POST handler's shared opening: readJsonObject reads the body and — when it is
13
13
  * oversized, malformed, or not a JSON object — has already written the 413/400 itself. Its
@@ -38,6 +38,24 @@ export async function readRoleBody(req, res, example, root, allowMissing = false
38
38
  return null; // 400 already sent
39
39
  return body;
40
40
  }
41
+ /** The sha-addressed POST handlers' shared prologue: readPostBody (400/413 sent on a null
42
+ * return) plus requireSha's hex-commit-id validation (400 sent on a null return), so the
43
+ * queue-cancel/queue-top/post-review-accept/revert family states the body-discipline-and-sha
44
+ * check pair once instead of two guard lines per handler. Its null return means the response
45
+ * is already written and the handler must stop; callers keep their own `if (!read) return`
46
+ * bail. Exactly four call sites — handleRevert, handleQueueCancel, handleQueueTop, and
47
+ * handlePostReviewAccept — returning the body too so handleRevert can read its optional
48
+ * dryRun; the other three callers use only the sha. A caller that needs neither the body nor
49
+ * the example can read just `{ sha }` from the result. */
50
+ export async function readShaBody(req, res) {
51
+ const body = await readPostBody(req, res, '{"sha": "1a2b3c4"}');
52
+ if (body === null)
53
+ return null; // 4xx already sent — oversized, malformed, or not an object
54
+ const sha = requireSha(res, body.sha);
55
+ if (sha === null)
56
+ return null; // 400 already sent
57
+ return { body, sha };
58
+ }
41
59
  /** Reply for a boolean-claiming marker request (RequestResult): success maps to 200 with its
42
60
  * message, refusal to 409 — the request is valid but nothing can act on it, the same conflict
43
61
  * reading requestAbort's not-live error uses. /api/restart and /api/abort share it. */
@@ -32,7 +32,7 @@ import { editBriefText } from "../brief/brief-edit.js";
32
32
  import { requireStringField, validRoleIds } from "./gui-args.js";
33
33
  import { badRequest, sendJson } from "./gui-http-body.js";
34
34
  import { errorMessage, gotSuffix } from "../text/text.js";
35
- import { readPostBody, readRoleBody, sendRequestResult } from "./gui-endpoint-common.js";
35
+ import { readPostBody, readRoleBody, readShaBody, sendRequestResult } from "./gui-endpoint-common.js";
36
36
  import { wakeInSecondsMs } from "./gui-team-commands.js";
37
37
  /** The shared body of the dashboard's Health/Action buttons that run a domain function and
38
38
  * answer its text lines joined with "; ": readPostBody (400/413 sent on a null return) plus the
@@ -249,20 +249,19 @@ export async function handleRetire(req, res, root) {
249
249
  * answers `{ ok, preview }` (previewRevert, writing nothing); otherwise the revert commits and
250
250
  * answers `{ ok, headSha, beforeSha, message }`. A core's refusal is a 409 with its wording. */
251
251
  export async function handleRevert(req, res, root) {
252
- const body = await readPostBody(req, res, '{"sha": "1a2b3c4"}');
253
- if (!body)
252
+ const parsed = await readShaBody(req, res);
253
+ if (parsed === null)
254
254
  return;
255
- if (typeof body.sha !== "string" || !/^[0-9a-f]{4,64}$/i.test(body.sha))
256
- return badRequest(res, `"sha" must be a hex commit id of 4-64 digits${gotSuffix(body.sha)}`);
255
+ const { body, sha } = parsed;
257
256
  if (body.dryRun !== undefined && typeof body.dryRun !== "boolean")
258
257
  return badRequest(res, `"dryRun" must be true or false${gotSuffix(body.dryRun)}`);
259
258
  if (body.dryRun === true) {
260
- const preview = await previewRevert(root, body.sha);
259
+ const preview = await previewRevert(root, sha);
261
260
  if (!preview.ok)
262
261
  return sendJson(res, 409, { error: preview.error });
263
262
  return sendJson(res, 200, { ok: true, preview });
264
263
  }
265
- const result = await revertMergedCommit(root, body.sha);
264
+ const result = await revertMergedCommit(root, sha);
266
265
  if (!result.ok)
267
266
  return sendJson(res, 409, { error: result.error });
268
267
  const followUp = revertFollowUp(result.subject);
@@ -292,14 +291,13 @@ function rejectInFlightMerge(res, root, sha) {
292
291
  * raced the click) is a 200 `{ status: "gone" }` data, not an error. Otherwise it answers the
293
292
  * cancelled entry's role/commit/summary and removes the queue file and pin. */
294
293
  export async function handleQueueCancel(req, res, root) {
295
- const body = await readPostBody(req, res, '{"sha": "1a2b3c4"}');
296
- if (!body)
294
+ const parsed = await readShaBody(req, res);
295
+ if (parsed === null)
297
296
  return;
298
- if (typeof body.sha !== "string" || !/^[0-9a-f]{4,64}$/i.test(body.sha))
299
- return badRequest(res, `"sha" must be a hex commit id of 4-64 digits${gotSuffix(body.sha)}`);
300
- if (rejectInFlightMerge(res, root, body.sha))
297
+ const { sha } = parsed;
298
+ if (rejectInFlightMerge(res, root, sha))
301
299
  return;
302
- const outcome = await cancelQueuedMergeBySha(root, body.sha);
300
+ const outcome = await cancelQueuedMergeBySha(root, sha);
303
301
  if (outcome.status === "gone")
304
302
  return sendJson(res, 200, { ok: true, status: "gone" });
305
303
  if (outcome.status !== "cancelled")
@@ -322,12 +320,11 @@ export async function handleQueueCancel(req, res, root) {
322
320
  * accepted like any pending one. Otherwise it answers the
323
321
  * accepted entry's role/commit/summary and removes the entry. */
324
322
  export async function handlePostReviewAccept(req, res, root) {
325
- const body = await readPostBody(req, res, '{"sha": "1a2b3c4"}');
326
- if (!body)
323
+ const parsed = await readShaBody(req, res);
324
+ if (parsed === null)
327
325
  return;
328
- if (typeof body.sha !== "string" || !/^[0-9a-f]{4,64}$/i.test(body.sha))
329
- return badRequest(res, `"sha" must be a hex commit id of 4-64 digits${gotSuffix(body.sha)}`);
330
- const outcome = acceptPostReviewBySha(root, body.sha);
326
+ const { sha } = parsed;
327
+ const outcome = acceptPostReviewBySha(root, sha);
331
328
  if (outcome.status === "gone")
332
329
  return sendJson(res, 200, { ok: true, status: "gone" });
333
330
  if (outcome.status === "settled")
@@ -352,18 +349,17 @@ export async function handlePostReviewAccept(req, res, root) {
352
349
  * Otherwise it answers the topped entry's role/commit/summary and moves its queue file to
353
350
  * the front. */
354
351
  export async function handleQueueTop(req, res, root) {
355
- const body = await readPostBody(req, res, '{"sha": "1a2b3c4"}');
356
- if (!body)
352
+ const parsed = await readShaBody(req, res);
353
+ if (parsed === null)
357
354
  return;
358
- if (typeof body.sha !== "string" || !/^[0-9a-f]{4,64}$/i.test(body.sha))
359
- return badRequest(res, `"sha" must be a hex commit id of 4-64 digits${gotSuffix(body.sha)}`);
360
- if (rejectInFlightMerge(res, root, body.sha))
355
+ const { sha } = parsed;
356
+ if (rejectInFlightMerge(res, root, sha))
361
357
  return;
362
- const outcome = moveQueuedMergeToTopBySha(root, body.sha);
358
+ const outcome = moveQueuedMergeToTopBySha(root, sha);
363
359
  if (outcome.status === "gone")
364
360
  return sendJson(res, 200, { ok: true, status: "gone" });
365
361
  if (outcome.status === "already-first")
366
- return sendJson(res, 200, { ok: true, status: "already-first", role: outcome.role, commit: outcome.sha, summary: outcome.summary, message: `${shortSha(body.sha)} is already at the front` });
362
+ return sendJson(res, 200, { ok: true, status: "already-first", role: outcome.role, commit: outcome.sha, summary: outcome.summary, message: `${shortSha(sha)} is already at the front` });
367
363
  if (outcome.status !== "topped")
368
364
  return;
369
365
  sendJson(res, 200, {
@@ -13,7 +13,7 @@ import { promptPreview } from "../inbox/inbox.js";
13
13
  import { cancelQueuedFile } from "../inbox/inbox-cancel.js";
14
14
  import { editQueuedFile } from "../inbox/inbox-edit.js";
15
15
  import { topQueuedFile } from "../inbox/inbox-top.js";
16
- import { parseDurationMs } from "../cli/cli-args.js";
16
+ import { durationLabel, parseDurationMs } from "../cli/cli-args.js";
17
17
  import { PAUSE_FOR_MAX_MS, submitRolePromptAndWake } from "../operator/operator-intent.js";
18
18
  import { DIRECTOR_ROLE } from "../roles/roles.js";
19
19
  import { rejectBadQueueFileName, rejectBadRole, requirePromptText } from "./gui-args.js";
@@ -70,7 +70,7 @@ function checkedPromptDefer(res, body) {
70
70
  return null;
71
71
  }
72
72
  if (ms > PAUSE_FOR_MAX_MS) {
73
- badRequest(res, `defer is capped at 90d${gotSuffix(body.defer)}`);
73
+ badRequest(res, `defer is capped at ${durationLabel(PAUSE_FOR_MAX_MS)}${gotSuffix(body.defer)}`);
74
74
  return null;
75
75
  }
76
76
  return Date.now() + ms;
@@ -13,7 +13,7 @@ import { captureStartupBuild, createReloadWatch, reexecSelf, watchReloadSupervis
13
13
  import { warnEvent, TUMWATER_SOURCE } from "../events/events.js";
14
14
  import { errorMessage } from "../text/text.js";
15
15
  import { handleBacklog, handleBacklogMatch, handleBrief, handleConfig, handleDiff, handleFailures, handleReport, handleHistory, handleLogs, handlePromptText, handleRun, handleRole, handleTranscript, handleDoctor, handleRetireList, } from "./gui-endpoints.js";
16
- import { handleAbort, handleAbortAll, handleBriefEdit, handleBudget, handleConfigSet, handleConfigUnset, handleRoleCap, handleRoleReviewAfterMerge, handleRoleEnable, handleRoleDisable, handleRoleInstances, handleRoleInterval, handleRoleInstructions, handleRoleModel, handleRoleQuietHours, handleNotifyTest, handlePause, handlePauseRole, handlePrompt, handleBacklogFile, handleBacklogCancel, handleBacklogRelease, handleBacklogTop, handleBacklogEdit, handlePromptCancel, handlePromptEdit, handlePromptRole, handlePromptTop, handleQuestionAnswer, handleReclaim, handleKillOrphans, handleDropBrokenPins, handleCommitBacklog, handleResyncInstall, handleRetireLeftovers, handleArchiveBacklog, handleRestart, handleStopSchedule, handleResetCounters, handleRoleNotes, handleRetire, handleRevert, handleQueueCancel, handleQueueTop, handlePostReviewAccept, handleQuarantineRelease, handleWake, handleWakeCancel, } from "./gui-endpoint-commands.js";
16
+ import { handleAbort, handleAbortAll, handleBriefEdit, handleBudget, handleConfigSet, handleConfigUnset, handleRoleCap, handleRoleEnable, handleRoleDisable, handleRoleInstances, handleRoleInterval, handleRoleInstructions, handleRoleModel, handleRoleQuietHours, handleNotifyTest, handlePause, handlePauseRole, handlePrompt, handleBacklogFile, handleBacklogCancel, handleBacklogRelease, handleBacklogTop, handleBacklogEdit, handlePromptCancel, handlePromptEdit, handlePromptRole, handlePromptTop, handleQuestionAnswer, handleReclaim, handleKillOrphans, handleDropBrokenPins, handleCommitBacklog, handleResyncInstall, handleRetireLeftovers, handleArchiveBacklog, handleRestart, handleStopSchedule, handleResetCounters, handleRoleNotes, handleRetire, handleRevert, handleQueueCancel, handleQueueTop, handlePostReviewAccept, handleQuarantineRelease, handleWake, handleWakeCancel, } from "./gui-endpoint-commands.js";
17
17
  import { sendJson } from "./gui-http-body.js";
18
18
  /** Constant-time credential comparison for the shared-token gate: `timingSafeEqual` throws
19
19
  * on unequal lengths, so the length equality is the guard. A naive `===` string compare
@@ -176,7 +176,6 @@ export function startGui(root, port, allInterfaces = false, token = "", watch =
176
176
  "POST /api/question-answer": (req, res) => handleQuestionAnswer(req, res, root),
177
177
  "POST /api/budget": (req, res) => handleBudget(req, res, root),
178
178
  "POST /api/role-cap": (req, res) => handleRoleCap(req, res, root),
179
- "POST /api/role-review-after-merge": (req, res) => handleRoleReviewAfterMerge(req, res, root),
180
179
  "POST /api/role-enable": (req, res) => handleRoleEnable(req, res, root),
181
180
  "POST /api/role-disable": (req, res) => handleRoleDisable(req, res, root),
182
181
  "POST /api/role-interval": (req, res) => handleRoleInterval(req, res, root),
@@ -11,6 +11,7 @@ import { cancelWakes, PAUSE_FOR_MAX_MS, requestWake } from "../operator/operator
11
11
  import { requirePausedFlag, validRoleIds } from "./gui-args.js";
12
12
  import { badRequest, sendJson } from "./gui-http-body.js";
13
13
  import { gotSuffix } from "../text/text.js";
14
+ import { durationLabel } from "../cli/cli-args.js";
14
15
  import { readPostBody, readRoleBody } from "./gui-endpoint-common.js";
15
16
  /** The shared optional-seconds rule behind the timed pause's `forSeconds` and the scheduled
16
17
  * wake/stop's `inSeconds`: the field is absent (undefined) or a positive finite number of
@@ -30,7 +31,7 @@ function boundedSecondsMs(res, body, field, capHint = "") {
30
31
  return null;
31
32
  }
32
33
  if (seconds * 1000 > PAUSE_FOR_MAX_MS) {
33
- badRequest(res, `${field} is capped at ${PAUSE_FOR_MAX_MS / 1000} seconds (90 days)${gotSuffix(seconds)}${capHint}`);
34
+ badRequest(res, `${field} is capped at ${durationLabel(PAUSE_FOR_MAX_MS)}${gotSuffix(seconds)}${capHint}`);
34
35
  return null;
35
36
  }
36
37
  return Math.round(seconds * 1000);
@@ -3,7 +3,7 @@ import { betweenWindow, durationLabel, failRivalShapes, failUntilShorterThanSinc
3
3
  import { HISTORY_DEFAULT_RUNS, HISTORY_MAX_RUNS, HISTORY_RESULT_FLAG_NAMES, HISTORY_SUMMARY_GROUPS, historyClass, parseHistoryResultClass, readRunRows, readRunRowsSince, runRows, summarizeRunRows, summarizeRunRowsByModel, runRowsMatchingGrep, runRowsOnModels } from "./history-data.js";
4
4
  import { renderModelSummary, renderRow, renderSummary, widenWidths, widestWidths } from "./history-render.js";
5
5
  import { LOGS_SINCE_MAX_MS, SPARSE_WINDOW_NOTE } from "../events/event-window.js";
6
- import { followUntilParentGone } from "../cli/log-commands.js";
6
+ import { followUntilParentGone } from "../cli/cli-log-commands.js";
7
7
  import { hasFollowFlag } from "../cli/cli-flag-specs.js";
8
8
  import { parseEventLine, readEventsTailWithEnd } from "../events/event-read.js";
9
9
  import { eventsLogPath } from "../paths.js";
@@ -23,7 +23,7 @@ export function parseHistoryModelList(raw) {
23
23
  return pieces;
24
24
  }
25
25
  /** `tumwater history [--role <id>[,<id>...]] [-n N]`: one row per completed run, newest first. The
26
- * observing half beside cmdLogs (log-commands.ts): read-only over the event log, stdout only —
26
+ * observing half beside cmdLogs (cli-log-commands.ts): read-only over the event log, stdout only —
27
27
  * every datum rides the `run_end` events src/run/run-finalize.ts already writes, so this
28
28
  * is a rendering of the existing record, not a new one. The rows themselves are collected
29
29
  * by history-data.ts (beside src/report/report-data.ts and src/failure/failure-data.ts), and
@@ -31,7 +31,7 @@ export function parseHistoryModelList(raw) {
31
31
  * src/report/report-render.ts); this module parses the args, drives the CLI, and prints. */
32
32
  /** The missing-pattern error cmdHistory prints for a valueless or empty `--grep`, exported so
33
33
  * cli.ts's rejectUnknownArgs spec for --grep can fail a trailing `history --grep` with the same
34
- * wording. Kept local to this module — log-commands.ts holds its own GREP_VALUE_ERROR — so the
34
+ * wording. Kept local to this module — cli-log-commands.ts holds its own GREP_VALUE_ERROR — so the
35
35
  * two observing views' wordings each name the command that printed them. */
36
36
  export const HISTORY_GREP_VALUE_ERROR = "history --grep needs a pattern";
37
37
  /** The missing-value error cmdHistory prints for a valueless or empty `--result`, exported so
@@ -192,7 +192,7 @@ function sayHistoryEmpty(q, filtered) {
192
192
  // `no runs in 2h` are statements about the LOG and the WINDOW, and both are false when
193
193
  // rows were scanned and filtered out — the operator would read a quiet team where the
194
194
  // truth is `the runs are there, none match`. The logs --grep rule (its `no events
195
- // matching "<pattern>"`, src/cli/log-commands.ts) names the pattern instead; --json stays
195
+ // matching "<pattern>"`, src/cli/cli-log-commands.ts) names the pattern instead; --json stays
196
196
  // a bare {"rows":[]} document above, never prose in either form.
197
197
  //
198
198
  // The result class is named only when it emptied a non-empty set (`afterGrep > 0`): an
@@ -3,7 +3,7 @@ import { mergeRefName } from "../paths.js";
3
3
  import { recordReview } from "../run/run-apply.js";
4
4
  import { saveRunnerStateBestEffort } from "../runner/runner-state.js";
5
5
  import { unverifiedTreeOutcome } from "../build/build-check-events.js";
6
- import { checkFailureReasons, describeCheck, failureHeadline } from "../build/build-check-report.js";
6
+ import { checkFailureReasons, redCheckHeadline } from "../build/build-check-report.js";
7
7
  import { onlyKnownMainFailures } from "../baseline/main-watch.js";
8
8
  import { mainTipVerdict } from "../baseline/main-red.js";
9
9
  import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
@@ -126,7 +126,7 @@ export async function mergeCheckRed(ctx, role, wt, red) {
126
126
  if (result === "merge_blocked" && !attributed) {
127
127
  warnEventBestEffort(ctx.root, role, `merge check failed (kept for one retry): ${reason}`);
128
128
  if (ctx.state.mergeCheckFailures) {
129
- ctx.state.mergeCheckFailures.headline = failureHeadline(red.outcome.outputTail) ?? describeCheck(red.check);
129
+ ctx.state.mergeCheckFailures.headline = redCheckHeadline(red.check, red.outcome.outputTail);
130
130
  }
131
131
  }
132
132
  return result;
@@ -143,15 +143,18 @@ export function noteMergeRetryPassed(ctx, role, pinPatch) {
143
143
  if (headline)
144
144
  warnEventBestEffort(ctx.root, role, MERGE_FLAKY_WARNING_PREFIX + headline);
145
145
  }
146
- /** A merge blocked by a deterministic cross-check (onMergeBlocked's fix-claim or
147
- * backlog-structure reason — a property of the tree ahead of main, not a flaky run). The
148
- * block is counted like a red merge check, keyed by the same patch-id: under
149
- * MERGE_CHECK_FAILURE_LIMIT the pin is kept for one recovery re-merge — main may have moved
150
- * under the rebase, and a differently-rebased tree can pass the cross-check — but at the
146
+ /** A merge blocked by a deterministic cross-check — a property of the tree ahead of main,
147
+ * not a flaky run. The block is counted like a red merge check, keyed by the same patch-id:
148
+ * under MERGE_CHECK_FAILURE_LIMIT the pin is kept for one recovery re-merge — main may have
149
+ * moved under the rebase, and a differently-rebased tree can pass the cross-check — but at the
151
150
  * limit the block is attributed to the change: rejected deterministically with the block
152
151
  * reason, the pin deleted, no model run. Without this a resolution that trips the same
153
152
  * cross-check on every re-resolve pays a fresh conflict-resolver run each run, forever
154
- * (BUGS.md 2026-10-02: five resolver sessions for one fix-claim-blocked pin). */
153
+ * (BUGS.md 2026-10-02: five resolver sessions for one fix-claim-blocked pin). Since part 3m/3
154
+ * the merge path no longer produces cross-check blocks (a fix claim and a broken backlog
155
+ * structure are repairable and land for the main watch), so nothing in the in-lock path calls
156
+ * this today; it stays as the strike-counting sink, still pinned by INV-BF2 and retired with
157
+ * the queue by 3f/3. */
155
158
  export async function mergeBlocked(ctx, role, wt, blocked) {
156
159
  return keepPinOrAttribute(ctx, wt, blocked, (head) => rejectChange(ctx, role, head, [`merge blocked: ${blocked}`], ctx.state));
157
160
  }
@@ -185,7 +188,7 @@ export async function attributeRedCheck(ctx, role, head, label, red, state) {
185
188
  const kept = keepForKnownFailures(ctx, role, label, red, state);
186
189
  if (kept)
187
190
  return kept;
188
- const headline = failureHeadline(red.outcome.outputTail) ?? describeCheck(red.check);
191
+ const headline = redCheckHeadline(red.check, red.outcome.outputTail);
189
192
  const key = normalizeClusterKey(headline);
190
193
  if (isKnownFlake(ctx.root, headline) && state.mergeFlakeExcused !== key) {
191
194
  state.mergeFlakeExcused = key;