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
package/dist/src/cli.js CHANGED
@@ -13,16 +13,14 @@
13
13
  * ready-repo gate. An error escaping main() funnels through its catch to fail(errorMessage(err)),
14
14
  * the CLI's one error path. */
15
15
  import { parseCountFlag, parseGrepFlag, parseModelFlag, parseResultFlag } from "./cli/cli-args.js";
16
- import { grepFlagSpec, GUI_FLAG_SPECS, JSON_FLAG, LAST_FLAG, N_FLAG, rejectUnknownArgs, resultFlagSpec, modelFlagSpec, byFlagSpec, LOGS_TRANSCRIPT_FLAG, ROLE_FLAG, START_FLAG_SPECS, sinceFlagSpec, untilFlagSpec, STAT_FLAG, PATCH_FLAG, PROMPT_FLAG, TRANSCRIPT_FLAG, SUMMARY_FLAG, COUNT_FLAG, NOTABLE_FLAG, typeFlagSpec, DRY_RUN_FLAG, FORCE_FLAG, FOLLOW_FLAG, STOP_CANCEL_FLAG, STOP_IN_FLAG, PROBLEMS_FLAG, KILL_ORPHANS_FLAG, COMMIT_BACKLOG_FLAG, RESYNC_INSTALL_FLAG, DROP_BROKEN_PINS_FLAG, RETIRE_LEFTOVERS_FLAG, } from "./cli/cli-flag-specs.js";
17
- import { fail, say, sayJsonOrRender } from "./cli/cli-output.js";
18
- import { historyTrailForm, parseDoctorChecks, parsePromptArgs, peelPositionals } from "./cli/cli-command-args.js";
16
+ import { grepFlagSpec, GUI_FLAG_SPECS, JSON_FLAG, LAST_FLAG, N_FLAG, rejectUnknownArgs, resultFlagSpec, modelFlagSpec, byFlagSpec, LOGS_TRANSCRIPT_FLAG, ROLE_FLAG, START_FLAG_SPECS, sinceFlagSpec, untilFlagSpec, STAT_FLAG, PATCH_FLAG, PROMPT_FLAG, TRANSCRIPT_FLAG, SUMMARY_FLAG, COUNT_FLAG, NOTABLE_FLAG, typeFlagSpec, DRY_RUN_FLAG, FORCE_FLAG, FOLLOW_FLAG, STOP_CANCEL_FLAG, STOP_IN_FLAG, } from "./cli/cli-flag-specs.js";
17
+ import { fail, say } from "./cli/cli-output.js";
18
+ import { historyTrailForm, parsePromptArgs, peelPositionals } from "./cli/cli-command-args.js";
19
19
  import { refuseTeamWorkspace, requireReadyRepo } from "./cli/cli-repo-gate.js";
20
- import { loadConfigSafe } from "./config/config.js";
21
20
  import { repoToplevel } from "./git/git.js";
22
21
  import { didYouMean } from "./text/suggest.js";
23
22
  import { errorMessage, gotSuffix, stripTerminalControls } from "./text/text.js";
24
23
  import { typoSuffix } from "./text/suggest.js";
25
- import { completionConfigKeys, completionModels, completionRoleIds, completionScript, configValueWords, SUPPORTED_SHELLS } from "./cli/completion.js";
26
24
  import { HELP, helpTopic, helpTopicForArgs, suggestCommand } from "./cli/help.js";
27
25
  import { PACKAGE_JSON, nodeFloorProblem, packageEnginesNode, packageVersion } from "./version.js";
28
26
  // The CLI's help text and its per-command topic parser live in help.ts — importing cli.ts
@@ -75,7 +73,7 @@ async function cmdGuiCase(root, args) {
75
73
  await (await import("./gui/gui-command.js")).cmdGui(root, args);
76
74
  }
77
75
  async function cmdConfigCase(root, args) {
78
- const { cmdConfig, CONFIG_USAGE } = await import("./cli/config-commands.js");
76
+ const { cmdConfig, CONFIG_USAGE } = await import("./cli/cli-config-commands.js");
79
77
  // Subcommand arity before the ready-repo gate, so a malformed get/set fails with its
80
78
  // usage no matter the directory: bare config takes nothing, `get`/`unset` exactly one
81
79
  // key, `set` exactly a key and a value. cmdConfig dispatches on the (now well-shaped)
@@ -125,105 +123,16 @@ async function cmdReportCase(root, args) {
125
123
  // trailing window vs a series over whole days), not a modifier of either.
126
124
  await (await import("./report/report.js")).cmdReport(root, args);
127
125
  }
128
- /** Run one doctor side-effect (`--kill-orphans`, `--commit-backlog`, `--resync-install`,
129
- * `--drop-broken-pins`, `--retire-leftovers`) and return its report payload for the optional
130
- * `--json` run — the shape every doctor*Case side-effect wrapper follows: import the effect's
131
- * module and run it only when its flag is present, then (text form only) print the effect's
132
- * own lines before the report with a separating blank line when there is anything to print;
133
- * `--json` keeps stdout one document, carrying the result on the payload instead. The lazy
134
- * per-effect `load` keeps each module imported only when its flag is asked for. */
135
- async function doctorSideEffect(args, flag, load) {
136
- if (!args.includes(flag))
137
- return undefined;
138
- const { run, lines } = await load();
139
- const payload = await run();
140
- // The text form prints the effect before the report; --json keeps stdout one document.
141
- if (!args.includes("--json")) {
142
- const out = lines(payload);
143
- for (const line of out)
144
- say(line);
145
- if (out.length > 0)
146
- say("");
147
- }
148
- return payload;
149
- }
150
- async function doctorKillOrphans(root, args) {
151
- return doctorSideEffect(args, "--kill-orphans", async () => {
152
- const { killOrphans, orphanKillLines } = await import("./doctor/doctor-orphans.js");
153
- return { run: () => killOrphans(root), lines: orphanKillLines };
154
- });
155
- }
156
- async function doctorCommitBacklog(root, args) {
157
- return doctorSideEffect(args, "--commit-backlog", async () => {
158
- const { commitUncommittedBacklog, backlogCommitLines } = await import("./doctor/doctor-backlog.js");
159
- const { config } = loadConfigSafe(root); // the report's own config, so base resolution and the off-base skip match the `uncommitted backlog` row's
160
- return { run: () => commitUncommittedBacklog(root, config), lines: backlogCommitLines };
161
- });
162
- }
163
- async function doctorResyncInstall(root, args) {
164
- return doctorSideEffect(args, "--resync-install", async () => {
165
- const { resyncRootInstall, rootInstallResyncLines } = await import("./build/dep-install.js");
166
- return { run: () => resyncRootInstall(root), lines: rootInstallResyncLines };
167
- });
168
- }
169
- async function doctorDropBrokenPins(root, args) {
170
- return doctorSideEffect(args, "--drop-broken-pins", async () => {
171
- const { dropBrokenPins, brokenPinDropLines } = await import("./doctor/doctor-contention-checks.js");
172
- return { run: () => dropBrokenPins(root), lines: brokenPinDropLines };
173
- });
174
- }
175
- async function doctorRetireLeftovers(root, args) {
176
- return doctorSideEffect(args, "--retire-leftovers", async () => {
177
- const { retireLeftovers, leftoverRetireLines } = await import("./doctor/doctor-contention-checks.js");
178
- const { config } = loadConfigSafe(root); // the row's own config, so the reap and the report agree on what is retirable
179
- return { run: () => retireLeftovers(root, config), lines: leftoverRetireLines };
180
- });
181
- }
182
- function doctorPayload(payload, parts) {
183
- const out = { ...payload };
184
- if (parts.leftoverRetire !== undefined)
185
- out.leftoverRetire = parts.leftoverRetire;
186
- if (parts.brokenPinDrop !== undefined)
187
- out.brokenPinDrop = parts.brokenPinDrop;
188
- if (parts.resyncInstall !== undefined)
189
- out.resyncInstall = parts.resyncInstall;
190
- if (parts.backlogCommit !== undefined)
191
- out.backlogCommit = parts.backlogCommit;
192
- if (parts.orphanKill !== undefined)
193
- out.orphanKill = parts.orphanKill;
194
- return out;
195
- }
196
126
  async function cmdDoctorCase(root, args) {
197
- // No requireReadyRepo gate: doctor's job is to report WHY the environment isn't ready,
198
- // so it must run outside a git repo and print fail lines rather than throwing.
199
- const { positionals: checkNames, rest: doctorFlags } = peelPositionals(args);
200
- rejectUnknownArgs("doctor", doctorFlags, [JSON_FLAG, PROBLEMS_FLAG, KILL_ORPHANS_FLAG, COMMIT_BACKLOG_FLAG, RESYNC_INSTALL_FLAG, DROP_BROKEN_PINS_FLAG, RETIRE_LEFTOVERS_FLAG]);
201
- const only = parseDoctorChecks(checkNames);
202
- const { runDoctor } = await import("./doctor/doctor.js");
203
- const parts = {
204
- orphanKill: await doctorKillOrphans(root, args),
205
- backlogCommit: await doctorCommitBacklog(root, args),
206
- resyncInstall: await doctorResyncInstall(root, args),
207
- brokenPinDrop: await doctorDropBrokenPins(root, args),
208
- leftoverRetire: await doctorRetireLeftovers(root, args),
209
- };
210
- const { renderDoctor, renderDoctorProblems, problemsOnly } = await import("./doctor/doctor-render.js");
211
- const report = await runDoctor(root, undefined, undefined, only);
212
- // --json prints the collector's own payload (the DoctorReport object), not a re-parse of
213
- // the render — the `report --json` precedent. The exit-code contract below holds in both
214
- // forms: 1 when any check fails, 0 otherwise; warnings never fail the exit.
215
- // --problems keeps the full report for the render (it counts the hidden ok rows) and
216
- // hands --json the filtered one; the exit code below reads the full report either way.
217
- // --kill-orphans adds the kill result to the JSON object as `orphanKill`.
218
- if (args.includes("--problems"))
219
- sayJsonOrRender(args, args.includes("--json") ? doctorPayload(problemsOnly(report), parts) : report, renderDoctorProblems);
220
- else
221
- sayJsonOrRender(args, args.includes("--json") ? doctorPayload(report, parts) : report, renderDoctor);
222
- if (report.checks.some((c) => c.level === "fail"))
223
- process.exitCode = 1; // Warnings never fail the exit.
127
+ // cmdDoctor lives in cli-doctor-command.ts (the doctor command body, its side-effect
128
+ // wrappers and payload shaping) and holds no ready-repo gate: doctor's job is to report
129
+ // WHY the environment isn't ready, so it must run outside a git repo and print fail
130
+ // lines rather than throwing. Imported lazily like every command body, so the doctor
131
+ // subgraph is only compiled when `tumwater doctor` is asked for.
132
+ await (await import("./cli/cli-doctor-command.js")).cmdDoctor(root, args);
224
133
  }
225
134
  async function cmdLogsCase(root, args) {
226
- const { cmdLogs, GREP_VALUE_ERROR, TYPE_VALUE_ERROR } = await import("./cli/log-commands.js");
135
+ const { cmdLogs, GREP_VALUE_ERROR, TYPE_VALUE_ERROR } = await import("./cli/cli-log-commands.js");
227
136
  rejectUnknownArgs("logs", args, [
228
137
  FOLLOW_FLAG,
229
138
  N_FLAG,
@@ -244,7 +153,7 @@ async function cmdLogsCase(root, args) {
244
153
  async function cmdNotifyCase(root, args) {
245
154
  rejectUnknownArgs("notify", args, [{ names: ["--test"] }]);
246
155
  await requireReadyRepo(root);
247
- await (await import("./cli/notify-command.js")).cmdNotify(root, args);
156
+ await (await import("./cli/cli-notify-command.js")).cmdNotify(root, args);
248
157
  }
249
158
  async function cmdHistoryCase(root, args) {
250
159
  const { cmdHistory, HISTORY_GREP_VALUE_ERROR, HISTORY_RESULT_VALUE_ERROR, HISTORY_MODEL_VALUE_ERROR, HISTORY_BY_VALUE_ERROR } = await import("./history/history.js");
@@ -382,71 +291,6 @@ async function cmdStopCase(root, args) {
382
291
  await requireReadyRepo(root);
383
292
  await (await import("./operator/operator-commands.js")).cmdStop(root, args);
384
293
  }
385
- function printCompletionRoles(root) {
386
- for (const id of completionRoleIds(root))
387
- say(id);
388
- }
389
- function printCompletionConfigKeys(root) {
390
- for (const key of completionConfigKeys(root))
391
- say(key);
392
- }
393
- function printCompletionConfigValue(key) {
394
- // The vocabulary `config set <key> <value>` completes for a discrete-valued key
395
- // (boolean/backend/model/thinking); free-form keys print nothing, so a `set` on one
396
- // completes nothing instead of offering a wrong vocabulary.
397
- for (const word of configValueWords(key))
398
- say(word);
399
- }
400
- function printCompletionModels(root) {
401
- for (const m of completionModels(root))
402
- say(m);
403
- }
404
- const COMPLETION_LISTING_SHAPES = {
405
- "--roles": "roles",
406
- "--config-keys": "config-keys",
407
- "--models": "models",
408
- };
409
- function completionListShape(args) {
410
- if (args.length === 1 && args[0] in COMPLETION_LISTING_SHAPES)
411
- return COMPLETION_LISTING_SHAPES[args[0]];
412
- if (args.length === 2 && args[0] === "--config-value")
413
- return "config-value";
414
- return null;
415
- }
416
- function completionShell(args) {
417
- const shell = args[0];
418
- // A wrong word count names the count, not a bad shell. A single near-miss shell word gets
419
- // the same typoSuffix `logs --type`/`history --result` give, so `completion zs` reads
420
- // "did you mean `zsh`?" while a far-off value (`tcsh`) stays hint-free.
421
- if (args.length === 0)
422
- fail("completion needs a shell: bash, zsh, or fish");
423
- if (args.length > 1)
424
- fail(`completion takes one shell: bash, zsh, or fish (got ${args.length} words: ${args.join(" ")})`);
425
- if (!SUPPORTED_SHELLS.includes(shell))
426
- fail(`completion takes one shell: bash, zsh, or fish${gotSuffix(shell)}${typoSuffix(shell ?? "", SUPPORTED_SHELLS)}`);
427
- return shell;
428
- }
429
- async function cmdCompletionCase(root, args) {
430
- // Works outside a repo: no requireReadyRepo.
431
- const shape = completionListShape(args);
432
- if (shape === "roles") {
433
- printCompletionRoles(root);
434
- return;
435
- }
436
- if (shape === "config-keys") {
437
- printCompletionConfigKeys(root);
438
- return;
439
- }
440
- if (shape === "config-value") {
441
- printCompletionConfigValue(args[1]);
442
- return;
443
- }
444
- if (shape === "models") {
445
- printCompletionModels(root);
446
- return;
447
- }
448
- say(completionScript(completionShell(args)));
449
- }
450
294
  async function cmdVersionCase(args) {
451
295
  // A stray flag fails like every other command's: `tumwater version --json` (a plausible
452
296
  // slip from `status --json`) must not print a version as if it had answered the query.
@@ -581,10 +425,10 @@ async function main() {
581
425
  break;
582
426
  }
583
427
  case "questions":
584
- await (await import("./cli/question-commands.js")).cmdQuestions(root, args);
428
+ await (await import("./cli/cli-question-commands.js")).cmdQuestions(root, args);
585
429
  break;
586
430
  case "brief":
587
- await (await import("./cli/brief-command.js")).cmdBrief(root, args);
431
+ await (await import("./cli/cli-brief-command.js")).cmdBrief(root, args);
588
432
  break;
589
433
  case "role":
590
434
  await (await import("./cli/cli-role-command.js")).cmdRole(root, args);
@@ -620,7 +464,11 @@ async function main() {
620
464
  break;
621
465
  }
622
466
  case "completion": {
623
- await cmdCompletionCase(root, args);
467
+ // Works outside a repo (no requireReadyRepo): the body lives in
468
+ // cli-completion-command.ts, beside its data (src/cli/completion.ts), and is imported
469
+ // lazily like every command body so the dispatcher never compiles the completion
470
+ // subgraph until `tumwater completion` is asked for.
471
+ await (await import("./cli/cli-completion-command.js")).cmdCompletion(root, args);
624
472
  break;
625
473
  }
626
474
  case "version":
@@ -18,9 +18,14 @@ export function groupBy(items, key) {
18
18
  /** The distinct items of an iterable, in first-seen order — the single home of the
19
19
  * `seen.includes -> push` dedupe, shared by the pending pane's three scope lists
20
20
  * (mergeQueueRoles, postReviewRoles, overlapHolders in ui/tui/tui-pane-pending.ts), the
21
- * session pane's scope cycle (ui/tui/tui-pane-session.ts's scopeCycle), and the report
22
- * scope fold (report/report-fold.ts's scopeBases), so those sites cannot drift on how a
23
- * repeat is skipped. Returns a fresh array; the input is never mutated. (The dashboard's
21
+ * session pane's scope cycle (ui/tui/tui-pane-session.ts's scopeCycle), the report
22
+ * scope fold (report/report-fold.ts's scopeBases), and the rest of the harness's
23
+ * `[...new Set(...)]` dedupes (completion.ts's commands/config keys, operator-commands.ts's
24
+ * role lists, text.ts's splitCommaListUnique/pathTokens, startup-gates.ts's backendsInUse,
25
+ * failure-data.ts's delta roles, doctor-backlog.ts's watched set, doctor-orphans.ts's pid
26
+ * list, main-watch.ts's knownMainFailures, help.ts's suggestCommand candidates, and
27
+ * tui-pane-activity.ts's present types), so those sites cannot drift on how a repeat is
28
+ * skipped. Returns a fresh array; the input is never mutated. (The dashboard's
24
29
  * role-scope builder in ui/gui/gui-client-report.ts keeps its own copy by design: it runs
25
30
  * in the browser, where harness modules cannot be imported.) */
26
31
  export function distinctOrdered(items) {
@@ -67,7 +72,7 @@ export function filterKeepPositions(items, keep) {
67
72
  * `map.set(k, (map.get(k) ?? 0) + 1)` step, shared by the failure-spread kind tally
68
73
  * (src/team/team-failure-spread.ts), the diff multiset's line tally (merge/merge-diff.ts), the
69
74
  * section-title tally (backlog/backlog-structure.ts), the logs' per-type tally
70
- * (cli/log-commands.ts), the flaky-check tally
75
+ * (cli/cli-log-commands.ts), the flaky-check tally
71
76
  * (doctor/doctor-project-checks.ts), and the multi-runner base tally
72
77
  * (orchestrator/orchestrator-scheduling.ts) — the exit-summary round's outcome tally
73
78
  * (cli/cli-start-summary.ts) seeds its own copy of this step inline — so those sites cannot
@@ -174,17 +179,9 @@ export function lowerMedian(values) {
174
179
  const sorted = [...values].sort((a, b) => a - b);
175
180
  return sorted[Math.floor((sorted.length - 1) / 2)];
176
181
  }
177
- /** Most-days-waited-first comparator, ties in ascending index (file order) — `b.days - a.days ||
178
- * a.index - b.index`, the single home of that one-line ordering. Shared by the stale-questions
179
- * feed (src/ui/alert-feeds.ts's staleOpenQuestions), which builds `{index, days}`
180
- * rows off the same backlog scan, so its tie order cannot drift. The generic holds
181
- * any row type carrying both fields; a row without a natural index passes its first-seen order. */
182
- export function byDaysThenIndex(a, b) {
183
- return b.days - a.days || a.index - b.index;
184
- }
185
182
  /** Ascending string comparator by UTF-16 code unit — the locale-independent order a bare `.sort()`
186
183
  * uses, unlike `localeCompare` (which `rankByCount` uses for its tiebreak). Used directly by
187
- * src/cli/cli-start-summary.ts, src/cli/completion.ts, src/cli/log-commands.ts,
184
+ * src/cli/cli-start-summary.ts, src/cli/completion.ts, src/cli/cli-log-commands.ts,
188
185
  * src/history/history-data.ts, src/review/post-merge-review.ts, src/review/quarantine-render.ts,
189
186
  * src/budget/budget.ts and src/change/change-data.ts, and by `sortByValueDesc` for its tiebreak. */
190
187
  export function compareCodeUnits(a, b) {
@@ -193,7 +190,7 @@ export function compareCodeUnits(a, b) {
193
190
  /** Rank `[key, value, …]` pairs by value descending, breaking ties in code-unit order
194
191
  * (compareCodeUnits — the locale-independent order, unlike rankByCount's localeCompare), so
195
192
  * equal values always order the same way. The single home of that comparator, which
196
- * src/cli/log-commands.ts's per-type event-count lines and src/report/report-render.ts's
193
+ * src/cli/cli-log-commands.ts's per-type event-count lines and src/report/report-render.ts's
197
194
  * mergeDone sort hand-rolled before; `…rest` lets a site carry extra trailing fields
198
195
  * (report-render's third column) through the sort untouched. Mutates and returns the input
199
196
  * array, matching the `.sort()` the call sites replaced. */
@@ -7,7 +7,7 @@ import { errCode } from "../errno.js";
7
7
  import { primaryWorktreeLockPath } from "../paths.js";
8
8
  /** How old a lock dir must be before it is stale on age alone — regardless of whether its
9
9
  * recorded pid still looks alive, so a reused pid cannot latch a dead holder as live. */
10
- const STALE_MS = 10 * 60 * 1000;
10
+ const STALE_MS = 10 * 60_000;
11
11
  /** A creator publishes its lock dir with the holder's pid already inside (atomic rename), so a
12
12
  * visible lock always carries a readable pid. This grace remains for a pid-less dir an older
13
13
  * build or an interrupted hand operation left behind: past it, the dir is an orphan and safe
@@ -82,24 +82,34 @@ export const DOTTED_SECTION_FIELDS = {
82
82
  * (`modle.small`, `maxDailyCostUsdPerRoll.feature`) gets the same did-you-mean the sibling
83
83
  * unknown-key errors attach. */
84
84
  const DOTTED_KEY_HEADS = [...DOTTED_MAP_KEYS, "roles", "model", "backends", ...Object.keys(DOTTED_SECTION_FIELDS)];
85
- /** Split a config key into its write/get shape: `foo` is top-level,
86
- * `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
87
- * `roles.qa.model` names one role-entry field, and anything else dotted is an error
88
- * naming the supported dotted forms. */
89
- export function parseConfigKey(key) {
90
- if (!key.includes("."))
91
- return { kind: "top", key };
92
- const parts = key.split(".");
85
+ /** The per-role map entry `maxDailyCostUsdPerRole.<role>` / `quietHoursPerRole.<role>` — a
86
+ * whole-value bare key with no deeper segment names one map entry. Returns undefined when
87
+ * the shape is not that of a two-segment per-role map entry. */
88
+ function parseMapEntry(parts) {
93
89
  const first = parts[0] ?? "";
94
90
  const second = parts.length > 1 ? parts[1] ?? "" : undefined;
95
91
  const third = parts.length > 2 ? parts[2] ?? "" : undefined;
96
92
  if (third === undefined && second !== undefined && second !== "" && DOTTED_MAP_KEYS.includes(first))
97
93
  return { kind: "map", map: first, role: second };
94
+ return undefined;
95
+ }
96
+ /** One role-entry field `roles.<id>.<field>` (e.g. `roles.qa.model`), with both segments
97
+ * non-empty. Returns undefined when the shape is not a three-segment `roles.` key. */
98
+ function parseRoleField(parts) {
99
+ const first = parts[0] ?? "";
100
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
101
+ const third = parts.length > 2 ? parts[2] ?? "" : undefined;
98
102
  if (parts.length === 3 && first === "roles" && second !== undefined && second !== "" && third !== undefined && third !== "")
99
103
  return { kind: "role", id: second, field: third };
100
- // `backends.<id>.<field>` names one field of one agent backend's entry (the way
101
- // `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
102
- // which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named.
104
+ return undefined;
105
+ }
106
+ /** `backends.<id>.<field>` names one field of one agent backend's entry (the way
107
+ * `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
108
+ * which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named. */
109
+ function parseBackendField(parts) {
110
+ const first = parts[0] ?? "";
111
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
112
+ const third = parts.length > 2 ? parts[2] ?? "" : undefined;
103
113
  if (parts.length === 3 && first === "backends" && second !== undefined && second !== "" && third !== undefined) {
104
114
  // configDir is claude/codex-only (CLAUDE_AGENT_KEYS / CODEX_BACKEND_KEYS):
105
115
  // a config set backends.pi.configDir would write a key nothing reads, so it is refused
@@ -112,10 +122,15 @@ export function parseConfigKey(key) {
112
122
  error: `unknown backend field "${third}" for backends.${second} (valid fields: ${validKeys.join(", ")})${typoSuffix(third, validKeys)}`,
113
123
  };
114
124
  }
115
- // `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
116
- // names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
117
- // must be a real tier: `config set model.turbo x` would otherwise write a dead map key
118
- // the runtime (and doctor's checkKnownKeys) would silently never resolve.
125
+ return undefined;
126
+ }
127
+ /** `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
128
+ * names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
129
+ * must be a real tier: `config set model.turbo x` would otherwise write a dead map key
130
+ * the runtime (and doctor's checkKnownKeys) would silently never resolve. */
131
+ function parseModelTier(parts) {
132
+ const first = parts[0] ?? "";
133
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
119
134
  if (parts.length === 2 && first === "model") {
120
135
  if ((TIER_MAP_KEYS).includes(second ?? ""))
121
136
  return { kind: "tier", map: "model", tier: second };
@@ -124,8 +139,13 @@ export function parseConfigKey(key) {
124
139
  error: `unknown model tier "${second ?? ""}" — a dotted \`model.<tier>\` names one of ${TIER_MAP_KEYS.join(", ")} (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`)${typoSuffix(second ?? "", TIER_MAP_KEYS)}`,
125
140
  };
126
141
  }
127
- // `<section>.<field>` names one field of a fixed-shape section (review, check, idleBackoff,
128
- // bootstrap); the field must be one the schema lists, or the write would be a dead key.
142
+ return undefined;
143
+ }
144
+ /** `<section>.<field>` names one field of a fixed-shape section (review, check, idleBackoff,
145
+ * bootstrap); the field must be one the schema lists, or the write would be a dead key. */
146
+ function parseSectionField(parts) {
147
+ const first = parts[0] ?? "";
148
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
129
149
  if (parts.length === 2 && Object.hasOwn(DOTTED_SECTION_FIELDS, first)) {
130
150
  const section = first;
131
151
  const fields = DOTTED_SECTION_FIELDS[section];
@@ -136,9 +156,14 @@ export function parseConfigKey(key) {
136
156
  error: `unknown ${first} field "${second ?? ""}" (valid fields: ${fields.join(", ")})${typoSuffix(second ?? "", fields)}`,
137
157
  };
138
158
  }
139
- // A typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`,
140
- // `modle.small`); when the head is one of the valid ones the shape itself is wrong, so
141
- // suggesting that head back would mislead — the generic wording is the whole story there.
159
+ return undefined;
160
+ }
161
+ /** The generic dotted-shape rejection: the head is not one of the accepted shapes in a way
162
+ * the earlier parsers would have claimed, so name the supported dotted forms wholesale. A
163
+ * typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`, `modle.small`);
164
+ * when the head is one of the valid ones the shape itself is wrong, so suggesting that head
165
+ * back would mislead — hence the hint only when the head is unknown. */
166
+ function dottedShapeError(key, first) {
142
167
  const headHint = DOTTED_KEY_HEADS.includes(first)
143
168
  ? ""
144
169
  : typoSuffix(first, DOTTED_KEY_HEADS);
@@ -147,6 +172,22 @@ export function parseConfigKey(key) {
147
172
  error: `unknown config key "${key}" — dotted keys name one entry of a per-role map or one field of a role entry (e.g. \`maxDailyCostUsdPerRole.feature 1.5\`, \`roles.qa.model x\`, \`review.model x\`); a bare key names a whole top-level value${headHint}`,
148
173
  };
149
174
  }
175
+ /** Split a config key into its write/get shape: `foo` is top-level,
176
+ * `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
177
+ * `roles.qa.model` names one role-entry field, and anything else dotted is an error
178
+ * naming the supported dotted forms. */
179
+ export function parseConfigKey(key) {
180
+ if (!key.includes("."))
181
+ return { kind: "top", key };
182
+ const parts = key.split(".");
183
+ const first = parts[0] ?? "";
184
+ return (parseMapEntry(parts) ??
185
+ parseRoleField(parts) ??
186
+ parseBackendField(parts) ??
187
+ parseModelTier(parts) ??
188
+ parseSectionField(parts) ??
189
+ dottedShapeError(key, first));
190
+ }
150
191
  /** The error `tumwater config get <key>` and `config set <key> <value>` both surface for a
151
192
  * key outside TOP_LEVEL_KEYS — the valid-keys list plus text.ts's did-you-mean suggestion —
152
193
  * or null when the key is known. One home for the phrasing so whichever verb misspells a
@@ -131,7 +131,17 @@ function withRolesSection(candidate, rawRolesCandidate, roles) {
131
131
  * throw, the old-key normalization and the top-level disallowed-key scan into `ignored`; the
132
132
  * `check` value is returned untouched for checkCommandFrom. */
133
133
  function parseConfigRequest(raw, ignored) {
134
- const unnormalized = JSON.parse(raw);
134
+ // A malformed request's JSON.parse SyntaxError is named as a config request so a role that
135
+ // staged garbage reads where it went wrong (the parser's position report is kept); the
136
+ // isJsonObject throw below owns the "not an object" wording, so only a true parse failure
137
+ // takes this path.
138
+ let unnormalized;
139
+ try {
140
+ unnormalized = JSON.parse(raw);
141
+ }
142
+ catch (err) {
143
+ throw new Error(`config request is not valid JSON: ${errorMessage(err)}`);
144
+ }
135
145
  if (!isJsonObject(unnormalized))
136
146
  throw new Error(`config request must be a JSON object (got ${show(unnormalized)})`);
137
147
  // Old key spellings read as the new ones; a request setting both fails with the pair named.
@@ -113,7 +113,7 @@ export const ROLE_ENTRY_KEYS = [
113
113
  "minRunIntervalSeconds",
114
114
  "instances",
115
115
  ];
116
- export const REVIEW_KEYS = ["enabled", "exemptPaths", "afterMerge", "provider", "model", "backend", "thinking", "timeoutSeconds"];
116
+ export const REVIEW_KEYS = ["enabled", "exemptPaths", "provider", "model", "backend", "thinking", "timeoutSeconds"];
117
117
  /** The `check` section's keys (plans/portability.md §6/7): the project's own verification
118
118
  * command, the optional cheaper gate-only command, their
119
119
  * working directory (relative to the worktree), and their timeout. */
@@ -1,6 +1,6 @@
1
1
  /** The per-role and cross-field section rules of tumwater.json validation: the `customRoles`
2
- * collect-and-check block, the `roles.<id>` entry block, `review.afterMerge`'s name check,
3
- * the per-role daily cost caps and quiet hours, and the `idleBackoff`
2
+ * collect-and-check block, the `roles.<id>` entry block, the per-role daily cost caps and
3
+ * quiet hours, and the `idleBackoff`
4
4
  * cross-field pair. Extracted from the single validateConfig gate (config-validation.ts) so a
5
5
  * per-role or cross-field edit lands as a per-theme diff instead of a whole-gate one; this
6
6
  * module runs once, at the gate's end, after every other section has appended its problems,
@@ -8,10 +8,10 @@
8
8
  * in config-field-checks.ts; the helpers passed in are the closures the gate binds once (the
9
9
  * problems-bound checkers and the shared model-triple sequence), and the
10
10
  * cross-cutting `customNames` set — collected in the customRoles block, consumed by the
11
- * roles.<id>, afterMerge, cap and quiet-hour blocks — is owned here, so validateConfig
11
+ * roles.<id>, cap and quiet-hour blocks — is owned here, so validateConfig
12
12
  * passes no set across the boundary. */
13
13
  import { CUSTOM_ROLE_KEYS, CUSTOM_NAME_RE, CUSTOM_TASK_MAX_CHARS, ROLE_ENTRY_KEYS, ROLE_INSTRUCTIONS_MAX_CHARS, } from "./config-schema.js";
14
- import { DIRECTOR_ROLE, allRoleIds } from "../roles/roles.js";
14
+ import { allRoleIds } from "../roles/roles.js";
15
15
  import { INSTANCE_ROLES, baseRoleOf } from "../roles/runner-ids.js";
16
16
  import { isJsonObject } from "../files/json-object.js";
17
17
  import { isNonBlankString } from "../text/text.js";
@@ -46,15 +46,14 @@ function checkPerRoleMap(r, key, expectation, customNames, problems, checkValue)
46
46
  }
47
47
  }
48
48
  /** Validate the per-role and cross-field sections that close the gate: `customRoles`, the
49
- * `roles.<id>` entries, `review.afterMerge`'s role names, the per-role caps and quiet hours,
50
- * and the `idleBackoff` cross-field pair,
49
+ * `roles.<id>` entries, the per-role caps and quiet hours, and the `idleBackoff`
50
+ * cross-field pair,
51
51
  * each appended to `problems` in the order the gate produced. The single call site in
52
52
  * validateConfig is the point where these problems were appended, so the order across the
53
53
  * whole pass is unchanged. */
54
54
  export function validateRolesAndCrossField(r, problems, helpers) {
55
55
  const customNames = validateCustomRoles(r, problems, helpers);
56
56
  validateRoleEntries(r, problems, helpers, customNames);
57
- validateReviewAfterMerge(r, problems, customNames);
58
57
  validatePerRoleCostCaps(r, problems, customNames);
59
58
  validatePerRoleQuietHours(r, problems, customNames);
60
59
  validateIdleBackoff(r, problems);
@@ -161,21 +160,6 @@ function validateRoleEntries(r, problems, helpers, customNames) {
161
160
  }
162
161
  }
163
162
  }
164
- /** Validate `review.afterMerge`'s role names: a typo'd id would silently keep that role
165
- * reviewed before merging; the director is refused because its changes always merge on a
166
- * review first. */
167
- function validateReviewAfterMerge(r, problems, customNames) {
168
- if (isJsonObject(r.review) && Array.isArray(r.review.afterMerge)) {
169
- r.review.afterMerge.forEach((id, i) => {
170
- if (typeof id !== "string" || id.trim() === "")
171
- return; // Already reported by checkStringArray.
172
- if (id === DIRECTOR_ROLE)
173
- problems.push(`review.afterMerge[${i}] must not be "${DIRECTOR_ROLE}" — its changes are always reviewed before merging`);
174
- else
175
- checkKnownRoleId("review.afterMerge", id, customNames, problems);
176
- });
177
- }
178
- }
179
163
  /** Validate the per-role daily cost caps (src/gates/role-cap-gates.ts): each key must name a
180
164
  * known role — built-in or customRoles — because a typo'd id would silently no-op the cap,
181
165
  * the exact silent-ignore class the `roles.<id>` check exists to prevent (the customNames set
@@ -226,7 +226,6 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
226
226
  checkKnownKeys(o, REVIEW_KEYS, "review", problems);
227
227
  checkBoolean(o, "review.", "enabled");
228
228
  checkStringArray(o, "review.", "exemptPaths");
229
- checkStringArray(o, "review.", "afterMerge");
230
229
  // An exemption pattern is matched against a repo-relative path (exemptions.ts), so a
231
230
  // pattern shaped like something git never emits can never match: the diff it was meant
232
231
  // to exempt gets a model review anyway, and the operator never learns the pattern was
@@ -12,6 +12,8 @@ import { modelPairName } from "../budget/budget.js";
12
12
  import { isJsonObject } from "../files/json-object.js";
13
13
  import { DIRECTOR_ROLE, roleById } from "../roles/roles.js";
14
14
  import { baseRoleOf } from "../roles/runner-ids.js";
15
+ import { sampleHostLoad } from "../build/host-sleep.js";
16
+ import { starvedDeadlineMs } from "../build/build-check-events.js";
15
17
  /** The effective `backends.<id>` entry as a run sees it (plans/agent-backends.md "Back-compat"):
16
18
  * the file's own entry, with the legacy top-level `agentBin`/`piArgs` read as the pi backend's
17
19
  * `bin`/`args` when the new form leaves a field absent. Pure — it never writes those legacy
@@ -165,14 +167,6 @@ export function roleSeamTier(config, role) {
165
167
  return "strong";
166
168
  return roleById(role)?.tier ?? "default";
167
169
  }
168
- /** Whether the review gate reviews this role's commits AFTER they merge (review.afterMerge
169
- * lists the role's base id). One home for the membership test the review gate (review.ts's
170
- * reviewAttempt), the runner's `already_queued` recovery arm (runner-run.ts) and the role
171
- * view (`tumwater role <id>` / the dashboard role drawer) all share, so an operator reading
172
- * one surface sees the same answer every consumer enforces. Pure, no I/O. */
173
- export function reviewedAfterMerge(config, role) {
174
- return (config.review.afterMerge ?? []).includes(baseRoleOf(role));
175
- }
176
170
  /** The config as seen by one role: role-level provider/model/thinking overrides applied
177
171
  * over the top-level values, plus the per-role minRunIntervalSeconds (a slow clock for
178
172
  * roles that should act rarely) falling back to the global value when unset. The role's
@@ -231,7 +225,9 @@ export function resolverConfig(config) {
231
225
  * the merge slot the whole queue waits on, and its only other limits were the run's budget
232
226
  * and the quiet watchdog: reviews measured 2026-09-22/23 ran 2.3 min median, 9.2 min p90 and
233
227
  * 24.6 min max, while earlier days had 1.5–3.5 h reviews. 15 min clears the ordinary review and
234
- * kills a wedged one in minutes instead of the run's hours. */
228
+ * kills a wedged one in minutes instead of the run's hours. Under a starved host the budget
229
+ * stretches by the load-to-cores ratio (see reviewRunConfig), so a progressing review on a
230
+ * loaded box is not killed at the wall by load it does not control. */
235
231
  export const REVIEW_TIMEOUT_S = 900;
236
232
  /** The reviewer's wall-clock floor while the budget fallback carries the team. The free model
237
233
  * is typically local and far slower per turn than the budgeted one it replaces, but a review
@@ -244,14 +240,19 @@ export const REVIEW_TIMEOUT_S = 900;
244
240
  export const FALLBACK_REVIEW_TIMEOUT_S = 3600;
245
241
  /** The config as seen by the gate's reviewer run: the reviewer's model wiring (reviewConfig)
246
242
  * with its own time budget — `review.timeoutSeconds`, default REVIEW_TIMEOUT_S — overriding
247
- * the run's. A smaller runTimeoutSeconds still wins. A still-progressing gate run gets one
248
- * extension of the same length (src/pi/pi-watchdogs.ts), so the effective ceiling on a large
249
- * diff's review is twice this budget. */
250
- export function reviewRunConfig(config) {
243
+ * the run's. A smaller runTimeoutSeconds still wins. Under a starved host the budget stretches
244
+ * by `starvedDeadlineMs` (the merge-scope precedent in build-check-events.ts): the load-to-cores
245
+ * ratio caps it at double the base, so on an ordinary quiet host the value is exactly today's
246
+ * budget while a progressing review on a loaded box is not killed at the wall by load it does
247
+ * not control. A still-progressing gate run gets one extension of the same length
248
+ * (src/pi/pi-watchdogs.ts), so the effective ceiling on a large diff's review is twice this
249
+ * stretched budget. `load` defaults to `sampleHostLoad` and is injectable for offline tests. */
250
+ export function reviewRunConfig(config, load = sampleHostLoad) {
251
251
  const cfg = reviewConfig(config);
252
+ const baseMs = (config.review.timeoutSeconds ?? REVIEW_TIMEOUT_S) * 1000;
252
253
  return {
253
254
  ...cfg,
254
- runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, config.review.timeoutSeconds ?? REVIEW_TIMEOUT_S),
255
+ runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, starvedDeadlineMs(baseMs, load()) / 1000),
255
256
  };
256
257
  }
257
258
  /** The optional provider/model/thinking fields of a resolved model seam, with each unset
@@ -276,8 +277,8 @@ export function resolvedModelFields(provider, model, thinking) {
276
277
  * provider/model/thinking triple formatted as one selector string, or an empty object when
277
278
  * no model id is resolved (pi's own default) — the key stays absent rather than empty, the
278
279
  * same empty-means-unset rule resolvedModelFields applies to the raw halves. One home of
279
- * that assembly and guard, shared by the two run-start event writers (runner-run.ts's run_start,
280
- * review-run.ts's review_start) and the per-role status snapshot (status-data.ts) so the three
280
+ * that assembly and guard, shared by the run-start event writer (runner-run.ts's run_start)
281
+ * and the per-role status snapshot (status-data.ts) so the two
281
282
  * observability surfaces cannot disagree about which model a run starts on. A non-pi backend is
282
283
  * named as the selector's provider namespace (backendQualifiedSelector), so a claude run reads
283
284
  * `claude/claude-sonnet-5-5` in every surface instead of a bare model id. */