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
@@ -1,3 +1,4 @@
1
+ import { distinctOrdered } from "../collections.js";
1
2
  import { checkMainBaseline } from "./main-baseline.js";
2
3
  import { baselineCheckLogger, clearHealKeyOnGreen, withMainTipWorktree } from "./main-red.js";
3
4
  import { CHECK_TIER } from "../concurrency/check-permit.js";
@@ -13,6 +14,22 @@ import { treeDiffFiles } from "../git/git-diff.js";
13
14
  import { mergingRoleOf } from "../events/event-read.js";
14
15
  import { isExemptDiff } from "../review/exemptions.js";
15
16
  import { diffRuleReasons } from "../review/diff-rules.js";
17
+ /** The main_check event payload for a settled verdict: the baseline sha and status, the run
18
+ * duration (0 when nothing ran), the covered-commit count, and, on a red, the failing tests and
19
+ * the quarantined-only marker. */
20
+ function mainCheckEvent(baseline, red, quarantined, covered, durationMs) {
21
+ return {
22
+ source: TUMWATER_SOURCE,
23
+ type: "main_check",
24
+ sha: baseline.sha,
25
+ status: red ? "failed" : "passed",
26
+ durationMs,
27
+ covered,
28
+ ...(red ? { failingTests: baseline.failingTests ?? [] } : {}),
29
+ ...(quarantined ? { quarantined } : {}),
30
+ ...(baseline.inheritedFrom !== undefined ? { inherited: true } : {}),
31
+ };
32
+ }
16
33
  /** The failing tests of each root's newest watch verdict, keyed by root. */
17
34
  const knownFailures = new Map();
18
35
  /** Main's known failures: the failing tests of the watch's newest verdict — main's tip, or the
@@ -20,7 +37,7 @@ const knownFailures = new Map();
20
37
  * (review/known-flakes.ts). Empty when the verdict is green, red with no parsed test, or none has
21
38
  * been recorded yet in this process, and nothing is quarantined. */
22
39
  export function knownMainFailures(root) {
23
- return [...new Set([...(knownFailures.get(root) ?? []), ...quarantinedTests(root)])];
40
+ return distinctOrdered([...(knownFailures.get(root) ?? []), ...quarantinedTests(root)]);
24
41
  }
25
42
  /** The failing tests of a failed check when every one of them is a known main failure; null when
26
43
  * the output names no test, any test is outside the known set, or none is known. A list at
@@ -76,8 +93,9 @@ export function createMainWatch(root, mainBranch, onCulprit) {
76
93
  }
77
94
  }
78
95
  async function watchOnce(mainHead, config) {
79
- // The deterministic diff rules ("Every commit is verified on main, part 1/3"): run the same
80
- // five checks the gate runs on a change, against main's new tip. A rule failure is a
96
+ // The deterministic diff rules ("Every commit is verified on main, part 1/3"): run the full
97
+ // five-check set the watch and gate share — the gate now runs only the three a later commit
98
+ // cannot repair — against main's new tip. A rule failure is a
81
99
  // deterministic red — it takes no confirming re-run and never quarantines — reported as a
82
100
  // warning naming the rule and the head (and each committing role, when a recent merged event
83
101
  // names one). It runs on every tip the watch covers in a poll — a build green, a build
@@ -106,7 +124,7 @@ export function createMainWatch(root, mainBranch, onCulprit) {
106
124
  await git(wt, "checkout", "-q", "--detach", "--force", commit);
107
125
  const parent = `${commit}^`;
108
126
  const files = (await treeDiffFiles(wt, parent, commit)) ?? [];
109
- const [ruleReason] = await diffRuleReasons(wt, parent, files, mergingRoleOf(root, commit) ?? "", isExemptDiff(files, config.review.exemptPaths));
127
+ const [ruleReason] = await diffRuleReasons(wt, parent, files, mergingRoleOf(root, commit) ?? "", isExemptDiff(files, config.review.exemptPaths), "watch");
110
128
  if (ruleReason !== undefined) {
111
129
  logEventBestEffort(root, {
112
130
  source: TUMWATER_SOURCE,
@@ -120,17 +138,11 @@ export function createMainWatch(root, mainBranch, onCulprit) {
120
138
  }
121
139
  }
122
140
  };
123
- if (!detectBuildCheck(root, config)) {
124
- // No declared build check: nothing to watch for a build red, but the diff rules still run on
125
- // the tip. The build-red path below is what never runs here.
126
- await withMainTipWorktree(root, mainBranch, async (wt) => {
127
- await watchRules(wt, lastWatched, await headOf(wt));
128
- });
129
- lastWatched = mainHead; // no declared check: nothing else to watch until main moves
130
- return;
131
- }
132
- await withMainTipWorktree(root, mainBranch, async (wt) => {
133
- const started = Date.now();
141
+ /** Run the declared build check on main's tip, re-running a red once and quarantining a flake
142
+ * whose re-run turns green. Returns the check's result and whether any run actually executed
143
+ * (false for a cache hit, a skip, or a sleep-spanned run). `config` and `root` are captured
144
+ * from `watchOnce`. */
145
+ async function runBaselineCheck(wt) {
134
146
  let ran = false;
135
147
  const onRun = baselineCheckLogger(root, TUMWATER_SOURCE);
136
148
  const hook = (run) => {
@@ -145,45 +157,31 @@ export function createMainWatch(root, mainBranch, onCulprit) {
145
157
  if (check.baseline?.status === "green")
146
158
  quarantineTests(root, first.failingTests, (first.failingTests ?? []).join("\n"));
147
159
  }
148
- const baseline = check.baseline;
149
- const previous = lastWatched;
150
- // A skipped or sleep-spanned run made no verdict: the head still counts as watched, so the
151
- // next poll does not start another run for it — but the diff rules still run on this tip.
152
- if (!baseline) {
153
- const tip = await headOf(wt);
154
- await watchRules(wt, previous, tip);
155
- return void (lastWatched = tip);
156
- }
157
- lastWatched = baseline.sha;
160
+ return { check, ran };
161
+ }
162
+ /** The covered-commit count for a main_check event: the first-parent commits between the
163
+ * previous and new watched heads, or 1 when there is no previous head. */
164
+ async function countCovered(wt, previous, sha) {
165
+ if (previous === undefined)
166
+ return 1;
167
+ const counted = Number(((await gitTry(wt, "rev-list", "--first-parent", "--count", `${previous}..${sha}`)) ?? "").trim());
168
+ return Number.isFinite(counted) && counted > 0 ? counted : 1;
169
+ }
170
+ /** Settle the verdict for a checked baseline: distinguish quarantined-only reds, record
171
+ * main's known failures, count the covered commits, and log the main_check event. Returns
172
+ * whether the tip settled red (a red naming tests that are not all quarantined). */
173
+ async function recordMainCheck(wt, baseline, previous, ran, started) {
158
174
  // A red naming only quarantined tests counts as green: no redeploy hold.
159
175
  const quarantined = baseline.status === "red" ? onlyQuarantined(root, baseline.failingTests) : null;
160
176
  const red = baseline.status === "red" && !quarantined;
161
177
  knownFailures.set(root, red ? (baseline.failingTests ?? []) : []);
162
- const counted = previous === undefined
163
- ? 1
164
- : Number(((await gitTry(wt, "rev-list", "--first-parent", "--count", `${previous}..${baseline.sha}`)) ?? "").trim());
165
- logEventBestEffort(root, {
166
- source: TUMWATER_SOURCE,
167
- type: "main_check",
168
- sha: baseline.sha,
169
- status: red ? "failed" : "passed",
170
- durationMs: ran ? Date.now() - started : 0,
171
- covered: Number.isFinite(counted) && counted > 0 ? counted : 1,
172
- ...(red ? { failingTests: baseline.failingTests ?? [] } : {}),
173
- ...(quarantined ? { quarantined } : {}),
174
- ...(baseline.inheritedFrom !== undefined ? { inherited: true } : {}),
175
- });
176
- // The diff rules judge the tip whether the build is green or red; the build-red path below
177
- // names the whole tip as the culprit, so the two red kinds are judged independently.
178
- await watchRules(wt, previous, baseline.sha);
179
- // A settled non-red verdict (a green, or a red naming only quarantined tests) clears the
180
- // heal-dedup key, so a failing set that returns after a green main is healed again. A
181
- // settled red verdict below does NOT clear it: the heal-dedup key stays set while main
182
- // is red.
183
- if (!red) {
184
- clearHealKeyOnGreen();
185
- return;
186
- }
178
+ const covered = await countCovered(wt, previous, baseline.sha);
179
+ logEventBestEffort(root, mainCheckEvent(baseline, red, quarantined, covered, ran ? Date.now() - started : 0));
180
+ return red;
181
+ }
182
+ /** Name a settled red tip as the culprit: its merging runner, the main_culprit event, and
183
+ * the onCulprit callback with the tip and its failing tests. */
184
+ function reportCulprit(baseline) {
187
185
  // The newest head is the culprit ("Every commit is verified on main, part 2/3"): the watch
188
186
  // checks each new red tip, so whatever it found belongs to the head it checked. Once per red
189
187
  // head — lastWatched already holds it, so a later poll does not attribute it again — name
@@ -201,6 +199,42 @@ export function createMainWatch(root, mainBranch, onCulprit) {
201
199
  failingTests: baseline.failingTests ?? [],
202
200
  tip: baseline.sha,
203
201
  });
202
+ }
203
+ if (!detectBuildCheck(root, config)) {
204
+ // No declared build check: nothing to watch for a build red, but the diff rules still run on
205
+ // the tip. The build-red path below is what never runs here.
206
+ await withMainTipWorktree(root, mainBranch, async (wt) => {
207
+ await watchRules(wt, lastWatched, await headOf(wt));
208
+ });
209
+ lastWatched = mainHead; // no declared check: nothing else to watch until main moves
210
+ return;
211
+ }
212
+ await withMainTipWorktree(root, mainBranch, async (wt) => {
213
+ const started = Date.now();
214
+ const { check, ran } = await runBaselineCheck(wt);
215
+ const baseline = check.baseline;
216
+ const previous = lastWatched;
217
+ // A skipped or sleep-spanned run made no verdict: the head still counts as watched, so the
218
+ // next poll does not start another run for it — but the diff rules still run on this tip.
219
+ if (!baseline) {
220
+ const tip = await headOf(wt);
221
+ await watchRules(wt, previous, tip);
222
+ return void (lastWatched = tip);
223
+ }
224
+ lastWatched = baseline.sha;
225
+ const red = await recordMainCheck(wt, baseline, previous, ran, started);
226
+ // The diff rules judge the tip whether the build is green or red; the build-red path below
227
+ // names the whole tip as the culprit, so the two red kinds are judged independently.
228
+ await watchRules(wt, previous, baseline.sha);
229
+ // A settled non-red verdict (a green, or a red naming only quarantined tests) clears the
230
+ // heal-dedup key, so a failing set that returns after a green main is healed again. A
231
+ // settled red verdict below does NOT clear it: the heal-dedup key stays set while main
232
+ // is red.
233
+ if (!red) {
234
+ clearHealKeyOnGreen();
235
+ return;
236
+ }
237
+ reportCulprit(baseline);
204
238
  });
205
239
  }
206
240
  return {
@@ -106,16 +106,10 @@ export function briefFile(root) {
106
106
  * on-purpose bare repo. An unreadable candidate counts: a brief that cannot be read is one
107
107
  * the roles cannot use, and the underlying firstBriefText throw is caught and reported as
108
108
  * true here — same subset doctor's `brief` warn covers, minus the bare-repo state that
109
- * doctor deems fine. Consumer: src/status/status-data.ts's snapshot. */
109
+ * doctor deems fine. Thin accessor over briefStatus, so this predicate and the snapshot's
110
+ * shared flags share one implementation. */
110
111
  export function briefBlockMissing(root) {
111
- if (briefCandidates(root).every((candidate) => !fs.existsSync(candidate)))
112
- return false;
113
- try {
114
- return (readBrief(root)?.prompt ?? "") === "";
115
- }
116
- catch {
117
- return true;
118
- }
112
+ return briefStatus(root).missing;
119
113
  }
120
114
  /** True when the resolved brief block exceeds the read cap (BRIEF_TEXT_MAX_CHARS) — the
121
115
  * always-on amber twin of the doctor `brief` row's over-cap warn. extractPrompt truncates
@@ -127,15 +121,34 @@ export function briefBlockMissing(root) {
127
121
  * a surrogate pair, while an at-cap block stays equal to it. A repo with no candidate
128
122
  * resolves null (0, false), and an unreadable candidate's readBrief throw is caught to false,
129
123
  * because briefBlockMissing already raises `no-brief` for that state; an empty block stays
130
- * briefBlockMissing's (its resolved length is 0). Consumer: src/status/status-data.ts's
131
- * snapshot. */
124
+ * briefBlockMissing's (its resolved length is 0). Thin accessor over briefStatus, so this
125
+ * predicate and the snapshot's shared flags share one implementation. */
132
126
  export function briefBlockOverCap(root) {
127
+ return briefStatus(root).overCap;
128
+ }
129
+ /** The always-on brief degraded-state flags, derived from ONE candidate scan and ONE file
130
+ * read — the per-second status snapshot's `noBrief`/`briefTruncated` fields, and the single
131
+ * implementation of both predicates: the standalone briefBlockMissing/briefBlockOverCap
132
+ * accessors delegate here, so the shared and standalone paths cannot disagree. The snapshot
133
+ * used to call those two accessors, each calling readBrief — which re-lists both candidates
134
+ * and re-reads the whole brief file — twice per poll, on a file that changes only when a role
135
+ * edits it. briefStatus is the degraded-state twin of readBrief's promise (briefFile +
136
+ * readBriefText derive from one scan): the two flags share one read, and each flag applies the
137
+ * value's own predicate. `missing` is true when a candidate exists but none resolves a
138
+ * non-empty block (or the resolving read threw); `overCap` true when the resolved block
139
+ * exceeds the cap. A repo with no candidate at all is neither (both false). */
140
+ export function briefStatus(root) {
141
+ if (briefCandidates(root).every((candidate) => !fs.existsSync(candidate))) {
142
+ return { missing: false, overCap: false };
143
+ }
144
+ let prompt = "";
133
145
  try {
134
- return (readBrief(root)?.prompt.length ?? 0) > BRIEF_TEXT_MAX_CHARS;
146
+ prompt = readBrief(root)?.prompt ?? "";
135
147
  }
136
148
  catch {
137
- return false;
149
+ return { missing: true, overCap: false }; // Unreadable — the briefBlockMissing subset.
138
150
  }
151
+ return { missing: prompt === "", overCap: prompt.length > BRIEF_TEXT_MAX_CHARS };
139
152
  }
140
153
  /** The project's brief text, extracted from the resolved brief file's managed section —
141
154
  * TUMWATER.md when it exists with markers, else README.md (plans/portability.md §7a/7). The
@@ -152,11 +152,10 @@ export function modelPairName(p) {
152
152
  * `strong` tiers resolved to a usable free pair (resolveTierFallbacks over the full usable
153
153
  * predicate — price AND breaker serving) — plus whether the review gate is on. The gate is
154
154
  * `paused` when `default` resolves to pause (nothing for the team's bulk to run on), or when
155
- * review is on and `strong` does (nothing could merge for the roles a pre-merge review gates, so
156
- * their authoring runs are wasted work — a `review.afterMerge` role's is not);
157
- * `fallback` otherwise, with the tiers that did resolve running their pairs and the rest held
158
- * per role. The single definition of the gate: the orchestrator enforces it and both dashboards
159
- * display it, so what an operator sees is what the scheduler is doing. */
155
+ * review is on and `strong` does; `fallback` otherwise, with the tiers that did resolve running
156
+ * their pairs and the rest held per role. This is the value the dashboards display; the
157
+ * orchestrator's role holds come from the per-tier set `budgetPausedRoles` (a tier whose own
158
+ * pair did not resolve), not from a reviewer-derived team-wide pause. */
160
159
  export function budgetGate(reached, tiers, reviewOn) {
161
160
  if (!reached)
162
161
  return "open";
@@ -125,6 +125,16 @@ export function failureHeadline(tail) {
125
125
  export function describeCheck(check) {
126
126
  return check.kind === "npm" ? `\`npm run ${check.script}\`` : `\`${check.command}\``;
127
127
  }
128
+ /** The single-line headline of a red check — the failureHeadline when the tail has one to
129
+ * offer (a test name or marker plus a non-framing message), else the check's describeCheck
130
+ * command name — the name the gate, merge, and review warnings print for a failed check.
131
+ * One definition for every red that names itself to a human: review-precheck's two
132
+ * flake/failure warnings, merge-to-main's merge-retry quarantine warning, and
133
+ * merge-check-failures's kept-pin headline record and attributeRedCheck absent-tail headline.
134
+ */
135
+ export function redCheckHeadline(check, tail) {
136
+ return failureHeadline(tail) ?? describeCheck(check);
137
+ }
128
138
  /** The machine-generated reasons a red check hands its author: the headline joined to the rest of
129
139
  * the clipped tail, so the compiler error sits right after it in the injected next-run note. The
130
140
  * headline is failureHeadline's — the first line that is not a stack frame — not outputTail[0]: a
@@ -33,8 +33,6 @@ function resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad) {
33
33
  * the one-permit-two-attempt rule. Prices the first attempt as its own build_check event and
34
34
  * returns the final outcome and its run duration. */
35
35
  async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutMs, config, sampleSleep, install, permitWait, cheaper, signal) {
36
- // One permit covers both attempts (the killed-check retry re-runs under the permit it
37
- // holds); durationMs prices the run itself, not the wait for a permit.
38
36
  let durationMs = 0;
39
37
  const raw = await withCheckPermit(config, MERGE_SCOPES.has(scope) ? CHECK_TIER.merge : CHECK_TIER.other, async () => {
40
38
  // One permit covers both attempts (the killed-check retry re-runs under the permit it
@@ -213,7 +213,8 @@ export function swapDist(root, dist, mainHead) {
213
213
  * swap can close the window, because the survivor is the supervisor and it exits on the dead
214
214
  * generation; the next `tumwater start` then cannot even load `dist/src/cli.js` and fails with
215
215
  * MODULE_NOT_FOUND, so the repo is wedged until someone renames `dist.prev` back by hand. The
216
- * supervisor calls this before it exits (src/cli/cli-start.ts, via superviseStart's onDone): while
216
+ * supervisor calls this before it exits (src/cli/cli-start-supervisor.ts, via superviseStart's
217
+ * onDone): while
217
218
  * it is still up, it renames the preserved old build back into place so the next boot works.
218
219
  *
219
220
  * Only acts when `dist` is missing and a `dist.prev` exists — both conditions together are the
@@ -228,11 +229,12 @@ export function recoverInterruptedSwap(root, dist) {
228
229
  fs.renameSync(prev, dist); // Atomic: dist is either the restored old build or still missing.
229
230
  return true;
230
231
  }
231
- /** The supervisor's end-of-supervision repair (src/cli/cli-start.ts wires this into
232
+ /** The supervisor's end-of-supervision repair (src/cli/cli-start-supervisor.ts wires this into
232
233
  * superviseStart's onDone): recover dist/ after an interrupted swap and, either way, put the
233
234
  * outcome on the event feed. Kept beside recoverInterruptedSwap so the CLI wiring is one call.
234
- * cli-start.ts imports this module statically, so the code is in memory before a swap can move
235
- * dist/ away — recovering via an import at call time would fail ERR_MODULE_NOT_FOUND exactly
235
+ * cli-start-supervisor.ts imports this module statically, so the code is in memory before a
236
+ * swap can move dist/ away — recovering via an import at call time would fail
237
+ * ERR_MODULE_NOT_FOUND exactly
236
238
  * when it is needed, the window this closes. `dist` defaults to this process's own dist
237
239
  * (build-info.distDir, a pure path computation captured at load), overridable so a test can
238
240
  * point it at a temp root. A failed restore is reported, never thrown: the hook runs on the
@@ -9,7 +9,7 @@
9
9
  import { knownRoleIdsCached, loadConfigSafe } from "../config/config.js";
10
10
  import { aheadOfMain, branchExists, countCommitsInRange, currentBranch, targetBranch } from "../git/git.js";
11
11
  import { gitTry } from "../git/git-run.js";
12
- import { aheadOfMainDiff, aheadOfMainFiles, changedFiles } from "../git/git-diff.js";
12
+ import { aheadOfMainDiff, aheadOfMainFiles, changedFiles, DIFF_MAX_BYTES } from "../git/git-diff.js";
13
13
  import { compareCodeUnits } from "../collections.js";
14
14
  import { isAbsolute } from "node:path";
15
15
  import { branchName } from "../paths.js";
@@ -44,9 +44,6 @@ export async function collectRoleChanges(root, roles, opts = {}) {
44
44
  const views = await Promise.all(roles.map((role) => collectRoleChangeAt(root, role, scope, opts)));
45
45
  return { mainBranch: scope.mainBranch, roles: views };
46
46
  }
47
- /** The uncommitted patch's cap, matching aheadOfMainDiff's — one shared bound so neither
48
- * half of the view can print unbounded text. */
49
- const DIFF_MAX_BYTES = 200_000;
50
47
  /** Collect the change the role holds: both halves of the view, or a degraded state
51
48
  * when the worktree or the base branch is missing. Never throws on a degraded team — a
52
49
  * query about a role that has not run yet must answer, not fail (report's rationale). */
@@ -0,0 +1,75 @@
1
+ import { completionConfigKeys, completionModels, completionRoleIds, completionScript, configValueWords, SUPPORTED_SHELLS, } from "./completion.js";
2
+ import { say, fail } from "./cli-output.js";
3
+ import { gotSuffix } from "../text/text.js";
4
+ import { typoSuffix } from "../text/suggest.js";
5
+ /** `tumwater completion`'s command body — the shell-completion script and its --roles/
6
+ * --config-keys/--config-value/--models listing shapes. The completion data lives in
7
+ * src/cli/completion.ts (the spec parser, the completionScript emitter and the read-only
8
+ * listing helpers); this module answers a `completion` invocation, split out of cli.ts so the
9
+ * dispatcher stays the pure shared sequence and this command body sits beside its data the
10
+ * way every other command body does. Imported lazily by cli.ts like each command body. */
11
+ function printCompletionRoles(root) {
12
+ for (const id of completionRoleIds(root))
13
+ say(id);
14
+ }
15
+ function printCompletionConfigKeys(root) {
16
+ for (const key of completionConfigKeys(root))
17
+ say(key);
18
+ }
19
+ function printCompletionConfigValue(key) {
20
+ // The vocabulary `config set <key> <value>` completes for a discrete-valued key
21
+ // (boolean/backend/model/thinking); free-form keys print nothing, so a `set` on one
22
+ // completes nothing instead of offering a wrong vocabulary.
23
+ for (const word of configValueWords(key))
24
+ say(word);
25
+ }
26
+ function printCompletionModels(root) {
27
+ for (const m of completionModels(root))
28
+ say(m);
29
+ }
30
+ const COMPLETION_LISTING_SHAPES = {
31
+ "--roles": "roles",
32
+ "--config-keys": "config-keys",
33
+ "--models": "models",
34
+ };
35
+ function completionListShape(args) {
36
+ if (args.length === 1 && args[0] in COMPLETION_LISTING_SHAPES)
37
+ return COMPLETION_LISTING_SHAPES[args[0]];
38
+ if (args.length === 2 && args[0] === "--config-value")
39
+ return "config-value";
40
+ return null;
41
+ }
42
+ function completionShell(args) {
43
+ const shell = args[0];
44
+ // A wrong word count names the count, not a bad shell. A single near-miss shell word gets
45
+ // the same typoSuffix `logs --type`/`history --result` give, so `completion zs` reads
46
+ // "did you mean `zsh`?" while a far-off value (`tcsh`) stays hint-free.
47
+ if (args.length === 0)
48
+ fail("completion needs a shell: bash, zsh, or fish");
49
+ if (args.length > 1)
50
+ fail(`completion takes one shell: bash, zsh, or fish (got ${args.length} words: ${args.join(" ")})`);
51
+ if (!SUPPORTED_SHELLS.includes(shell))
52
+ fail(`completion takes one shell: bash, zsh, or fish${gotSuffix(shell)}${typoSuffix(shell ?? "", SUPPORTED_SHELLS)}`);
53
+ return shell;
54
+ }
55
+ /** Answer a `tumwater completion` invocation. Works outside a repo (no requireReadyRepo). */
56
+ export async function cmdCompletion(root, args) {
57
+ const shape = completionListShape(args);
58
+ if (shape === "roles") {
59
+ printCompletionRoles(root);
60
+ return;
61
+ }
62
+ if (shape === "config-keys") {
63
+ printCompletionConfigKeys(root);
64
+ return;
65
+ }
66
+ if (shape === "config-value") {
67
+ printCompletionConfigValue(args[1]);
68
+ return;
69
+ }
70
+ if (shape === "models") {
71
+ printCompletionModels(root);
72
+ return;
73
+ }
74
+ say(completionScript(completionShell(args)));
75
+ }
@@ -0,0 +1,103 @@
1
+ import { rejectUnknownArgs, JSON_FLAG, PROBLEMS_FLAG, KILL_ORPHANS_FLAG, COMMIT_BACKLOG_FLAG, RESYNC_INSTALL_FLAG, DROP_BROKEN_PINS_FLAG, RETIRE_LEFTOVERS_FLAG, } from "./cli-flag-specs.js";
2
+ import { say, sayJsonOrRender } from "./cli-output.js";
3
+ import { parseDoctorChecks, peelPositionals } from "./cli-command-args.js";
4
+ import { loadConfigSafe } from "../config/config.js";
5
+ /** Run one doctor side-effect (`--kill-orphans`, `--commit-backlog`, `--resync-install`,
6
+ * `--drop-broken-pins`, `--retire-leftovers`) and return its report payload for the optional
7
+ * `--json` run — the shape every doctor*Case side-effect wrapper follows: import the effect's
8
+ * module and run it only when its flag is present, then (text form only) print the effect's
9
+ * own lines before the report with a separating blank line when there is anything to print;
10
+ * `--json` keeps stdout one document, carrying the result on the payload instead. The lazy
11
+ * per-effect `load` keeps each module imported only when its flag is asked for. */
12
+ async function doctorSideEffect(args, flag, load) {
13
+ if (!args.includes(flag))
14
+ return undefined;
15
+ const { run, lines } = await load();
16
+ const payload = await run();
17
+ // The text form prints the effect before the report; --json keeps stdout one document.
18
+ if (!args.includes("--json")) {
19
+ const out = lines(payload);
20
+ for (const line of out)
21
+ say(line);
22
+ if (out.length > 0)
23
+ say("");
24
+ }
25
+ return payload;
26
+ }
27
+ async function doctorKillOrphans(root, args) {
28
+ return doctorSideEffect(args, "--kill-orphans", async () => {
29
+ const { killOrphans, orphanKillLines } = await import("../doctor/doctor-orphans.js");
30
+ return { run: () => killOrphans(root), lines: orphanKillLines };
31
+ });
32
+ }
33
+ async function doctorCommitBacklog(root, args) {
34
+ return doctorSideEffect(args, "--commit-backlog", async () => {
35
+ const { commitUncommittedBacklog, backlogCommitLines } = await import("../doctor/doctor-backlog.js");
36
+ const { config } = loadConfigSafe(root); // the report's own config, so base resolution and the off-base skip match the `uncommitted backlog` row's
37
+ return { run: () => commitUncommittedBacklog(root, config), lines: backlogCommitLines };
38
+ });
39
+ }
40
+ async function doctorResyncInstall(root, args) {
41
+ return doctorSideEffect(args, "--resync-install", async () => {
42
+ const { resyncRootInstall, rootInstallResyncLines } = await import("../build/dep-install.js");
43
+ return { run: () => resyncRootInstall(root), lines: rootInstallResyncLines };
44
+ });
45
+ }
46
+ async function doctorDropBrokenPins(root, args) {
47
+ return doctorSideEffect(args, "--drop-broken-pins", async () => {
48
+ const { dropBrokenPins, brokenPinDropLines } = await import("../doctor/doctor-contention-checks.js");
49
+ return { run: () => dropBrokenPins(root), lines: brokenPinDropLines };
50
+ });
51
+ }
52
+ async function doctorRetireLeftovers(root, args) {
53
+ return doctorSideEffect(args, "--retire-leftovers", async () => {
54
+ const { retireLeftovers, leftoverRetireLines } = await import("../doctor/doctor-contention-checks.js");
55
+ const { config } = loadConfigSafe(root); // the row's own config, so the reap and the report agree on what is retirable
56
+ return { run: () => retireLeftovers(root, config), lines: leftoverRetireLines };
57
+ });
58
+ }
59
+ function doctorPayload(payload, parts) {
60
+ const out = { ...payload };
61
+ if (parts.leftoverRetire !== undefined)
62
+ out.leftoverRetire = parts.leftoverRetire;
63
+ if (parts.brokenPinDrop !== undefined)
64
+ out.brokenPinDrop = parts.brokenPinDrop;
65
+ if (parts.resyncInstall !== undefined)
66
+ out.resyncInstall = parts.resyncInstall;
67
+ if (parts.backlogCommit !== undefined)
68
+ out.backlogCommit = parts.backlogCommit;
69
+ if (parts.orphanKill !== undefined)
70
+ out.orphanKill = parts.orphanKill;
71
+ return out;
72
+ }
73
+ /** `tumwater doctor`: report what in the environment is keeping the team from running, and
74
+ * run the optional repair side-effects. No ready-repo gate: doctor's job is to report WHY the
75
+ * environment isn't ready, so it must run outside a git repo and print fail lines rather than
76
+ * throwing. */
77
+ export async function cmdDoctor(root, args) {
78
+ const { positionals: checkNames, rest: doctorFlags } = peelPositionals(args);
79
+ rejectUnknownArgs("doctor", doctorFlags, [JSON_FLAG, PROBLEMS_FLAG, KILL_ORPHANS_FLAG, COMMIT_BACKLOG_FLAG, RESYNC_INSTALL_FLAG, DROP_BROKEN_PINS_FLAG, RETIRE_LEFTOVERS_FLAG]);
80
+ const only = parseDoctorChecks(checkNames);
81
+ const { runDoctor } = await import("../doctor/doctor.js");
82
+ const parts = {
83
+ orphanKill: await doctorKillOrphans(root, args),
84
+ backlogCommit: await doctorCommitBacklog(root, args),
85
+ resyncInstall: await doctorResyncInstall(root, args),
86
+ brokenPinDrop: await doctorDropBrokenPins(root, args),
87
+ leftoverRetire: await doctorRetireLeftovers(root, args),
88
+ };
89
+ const { renderDoctor, renderDoctorProblems, problemsOnly } = await import("../doctor/doctor-render.js");
90
+ const report = await runDoctor(root, undefined, undefined, only);
91
+ // --json prints the collector's own payload (the DoctorReport object), not a re-parse of
92
+ // the render — the `report --json` precedent. The exit-code contract below holds in both
93
+ // forms: 1 when any check fails, 0 otherwise; warnings never fail the exit.
94
+ // --problems keeps the full report for the render (it counts the hidden ok rows) and
95
+ // hands --json the filtered one; the exit code below reads the full report either way.
96
+ // --kill-orphans adds the kill result to the JSON object as `orphanKill`.
97
+ if (args.includes("--problems"))
98
+ sayJsonOrRender(args, args.includes("--json") ? doctorPayload(problemsOnly(report), parts) : report, renderDoctorProblems);
99
+ else
100
+ sayJsonOrRender(args, args.includes("--json") ? doctorPayload(report, parts) : report, renderDoctor);
101
+ if (report.checks.some((c) => c.level === "fail"))
102
+ process.exitCode = 1; // Warnings never fail the exit.
103
+ }
@@ -1,7 +1,7 @@
1
1
  /** `tumwater init`'s implementation (src/init/init.ts does the work; this prints the report).
2
2
  * Split out of cli/cli-start.ts, whose name is for start alone: every command it dispatches
3
- * delegates to its own module (operator/operator-commands.ts, config-commands.ts, log-commands.ts,
4
- * doctor.ts, …), and init is no exception. cli.ts stays the pure dispatcher. */
3
+ * delegates to its own module (cli-config-commands.ts, cli-log-commands.ts, doctor.ts, …), and init
4
+ * is no exception. cli.ts stays the pure dispatcher. */
5
5
  import { say } from "./cli-output.js";
6
6
  import { parseInitArgs } from "./cli-command-args.js";
7
7
  import { initProject } from "../init/init.js";
@@ -1,6 +1,6 @@
1
1
  /** The read-only query commands' bodies (status, diff, queue, backlog), split out of
2
2
  * cli.ts's dispatcher: main() routes to a cmd* here exactly as it routes to operator-commands's
3
- * cmdAbort or config-commands's cmdConfig, and cli.ts keeps only routing and the help/version
3
+ * cmdAbort or cli-config-commands's cmdConfig, and cli.ts keeps only routing and the help/version
4
4
  * surfaces. Each command owns its argument gate, ready-repo gating decision, and output shape,
5
5
  * with the wording and gating order moved verbatim from main()'s cases. */
6
6
  import { fail, say, sayJson, sayJsonOrRender } from "./cli-output.js";
@@ -1,7 +1,7 @@
1
1
  /** `tumwater role <id>`'s command body, split out of cli-query-commands.ts so that module
2
2
  * keeps only the read-only query commands (status, diff, queue, backlog). cli.ts's dispatcher
3
3
  * routes to cmdRole here exactly as it routes cmdStatus to cli-query-commands or cmdConfig to
4
- * config-commands. cmdRole is the one query-named command that also writes: it renders a
4
+ * cli-config-commands. cmdRole is the one query-named command that also writes: it renders a
5
5
  * role's standing and next-run prompts and carries eleven per-role write flags (notes,
6
6
  * instructions, quiet-hours, daily cap, min-run-interval, instances, enable/disable, and the
7
7
  * provider/model/thinking/backend), each through the same setConfigKey/unsetConfigKey core
@@ -56,10 +56,6 @@ const INSTANCES_SPEC = { names: ["--instances"], value: true, valueName: "<n>" }
56
56
  * roles.<base>.enabled. */
57
57
  const ENABLE_SPEC = { names: ["--enable"] };
58
58
  const DISABLE_SPEC = { names: ["--disable"] };
59
- /** `role`'s review-after-merge write flags: add the role to, or remove it from,
60
- * review.afterMerge (written for the base id). */
61
- const REVIEW_AFTER_MERGE_SPEC = { names: ["--review-after-merge"] };
62
- const CLEAR_REVIEW_AFTER_MERGE_SPEC = { names: ["--clear-review-after-merge"] };
63
59
  /** `role`'s model-triple write flags: clear the field, or set it, written to
64
60
  * roles.<base>.provider/.model/.thinking. model and provider take an id; thinking takes a
65
61
  * pi `--thinking` level (off|minimal|low|medium|high|xhigh|max). */
@@ -175,7 +171,7 @@ export async function cmdRole(root, args) {
175
171
  if (next?.startsWith("--"))
176
172
  fail(`${flag} needs a value (the next token ${JSON.stringify(next)} is a flag, not a value)`);
177
173
  }
178
- rejectUnknownArgs("role", rest, [ROLE_FLAG, JSON_FLAG, CLEAR_NOTES_SPEC, NOTES_FILE_SPEC, CLEAR_INSTRUCTIONS_SPEC, INSTRUCTIONS_FILE_SPEC, CLEAR_QUIET_HOURS_SPEC, QUIET_HOURS_SPEC, ENABLE_SPEC, DISABLE_SPEC, CLEAR_CAP_SPEC, CAP_SPEC, CLEAR_MIN_RUN_INTERVAL_SPEC, MIN_RUN_INTERVAL_SPEC, CLEAR_INSTANCES_SPEC, INSTANCES_SPEC, CLEAR_PROVIDER_SPEC, PROVIDER_SPEC, CLEAR_MODEL_SPEC, MODEL_SPEC, CLEAR_THINKING_SPEC, THINKING_SPEC, CLEAR_BACKEND_SPEC, BACKEND_SPEC, REVIEW_AFTER_MERGE_SPEC, CLEAR_REVIEW_AFTER_MERGE_SPEC]);
174
+ rejectUnknownArgs("role", rest, [ROLE_FLAG, JSON_FLAG, CLEAR_NOTES_SPEC, NOTES_FILE_SPEC, CLEAR_INSTRUCTIONS_SPEC, INSTRUCTIONS_FILE_SPEC, CLEAR_QUIET_HOURS_SPEC, QUIET_HOURS_SPEC, ENABLE_SPEC, DISABLE_SPEC, CLEAR_CAP_SPEC, CAP_SPEC, CLEAR_MIN_RUN_INTERVAL_SPEC, MIN_RUN_INTERVAL_SPEC, CLEAR_INSTANCES_SPEC, INSTANCES_SPEC, CLEAR_PROVIDER_SPEC, PROVIDER_SPEC, CLEAR_MODEL_SPEC, MODEL_SPEC, CLEAR_THINKING_SPEC, THINKING_SPEC, CLEAR_BACKEND_SPEC, BACKEND_SPEC]);
179
175
  refuseTeamWorkspace(root);
180
176
  // The id is required: positional (`tumwater role <id>`) or --role <id> (the flag
181
177
  // spelling every other role-targeting command shares). Both at once is a mistake, not
@@ -211,9 +207,6 @@ export async function cmdRole(root, args) {
211
207
  const backendWrite = clearBackend || rest.includes("--backend");
212
208
  const enable = rest.includes("--enable");
213
209
  const disable = rest.includes("--disable");
214
- const reviewAfterMerge = rest.includes("--review-after-merge");
215
- const clearReviewAfterMerge = rest.includes("--clear-review-after-merge");
216
- const reviewAfterMergeWrite = reviewAfterMerge || clearReviewAfterMerge;
217
210
  const writes = [];
218
211
  if (notesWrite)
219
212
  writes.push("notes");
@@ -239,8 +232,6 @@ export async function cmdRole(root, args) {
239
232
  writes.push("enable");
240
233
  if (disable)
241
234
  writes.push("disable");
242
- if (reviewAfterMergeWrite)
243
- writes.push("review-after-merge");
244
235
  if (writes.length > 1)
245
236
  fail(`${writes.join(" and ")} writes cannot share one invocation`);
246
237
  if (notesWrite) {
@@ -443,36 +434,6 @@ export async function cmdRole(root, args) {
443
434
  say(`${base}: ${state}`);
444
435
  return;
445
436
  }
446
- // The review-after-merge write: the one command the post-merge churn row's remedy
447
- // needs — add the role to, or remove it from, review.afterMerge for the base id, the
448
- // membership test reviewedAfterMerge shares. The whole-list value lands through the same
449
- // setConfigKey core `config set review.afterMerge <json>` uses, so validateConfig owns the
450
- // string-array shape, the known-role-id check and the `review.afterMerge must not be
451
- // "director"` refusal, and the write is atomic. A role already in the asked-for state is
452
- // reported as such and the file is left alone (the section branch compares the value
453
- // against the resolved list, so a no-op reports unchanged and writes nothing).
454
- if (reviewAfterMergeWrite) {
455
- if (reviewAfterMerge && clearReviewAfterMerge)
456
- fail("give --review-after-merge or --clear-review-after-merge, not both");
457
- if (rest.includes("--json"))
458
- fail("--json does not apply to a review-after-merge write");
459
- requireKnownRole(root, id);
460
- const base = baseRoleOf(id);
461
- const list = loadConfigSafe(root).config?.review.afterMerge ?? [];
462
- const has = list.includes(base);
463
- const next = has === reviewAfterMerge ? list : reviewAfterMerge ? [...list, base] : list.filter((r) => r !== base);
464
- const result = setConfigKey(root, "review.afterMerge", JSON.stringify(next));
465
- if (!result.ok)
466
- fail(result.error);
467
- say(result.unchanged
468
- ? reviewAfterMerge
469
- ? `${base}: already in review.afterMerge, so tumwater.json was not written`
470
- : `${base}: not in review.afterMerge, so nothing was cleared`
471
- : reviewAfterMerge
472
- ? `${base}: added to review.afterMerge`
473
- : `${base}: removed from review.afterMerge`);
474
- return;
475
- }
476
437
  // The unknown-role answer (exit 1, unknownRoleMessage's wording) lives in the
477
438
  // collector; here the id just has to be non-null for the thunk's types.
478
439
  sayJsonOrRender(rest, () => rolePayload(root, id), renderRoleMarkdown);