tumwater 0.3.0 → 0.4.0

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 (217) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +13 -0
  4. package/dist/src/agent/claude/claude-backend.js +2 -2
  5. package/dist/src/agent/claude/claude-stream.js +3 -2
  6. package/dist/src/agent/codex/codex-backend.js +3 -3
  7. package/dist/src/agent/codex/codex-stream.js +3 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  9. package/dist/src/backlog/backlog-archive.js +60 -25
  10. package/dist/src/backlog/backlog-cli-view.js +3 -28
  11. package/dist/src/backlog/backlog-cli.js +9 -64
  12. package/dist/src/backlog/backlog-commit.js +17 -9
  13. package/dist/src/backlog/backlog-eligibility.js +44 -26
  14. package/dist/src/backlog/backlog-parts.js +23 -13
  15. package/dist/src/backlog/backlog-release.js +33 -16
  16. package/dist/src/baseline/main-guardrails.js +0 -73
  17. package/dist/src/baseline/main-red.js +31 -21
  18. package/dist/src/baseline/main-watch.js +82 -34
  19. package/dist/src/brief/brief-edit.js +3 -1
  20. package/dist/src/budget/budget-fallback-view.js +288 -0
  21. package/dist/src/budget/budget-wiring.js +158 -0
  22. package/dist/src/budget/budget.js +2 -1
  23. package/dist/src/build/dep-install.js +64 -1
  24. package/dist/src/cli/cli-args.js +63 -0
  25. package/dist/src/cli/cli-flag-specs.js +33 -0
  26. package/dist/src/cli/cli-query-commands.js +16 -41
  27. package/dist/src/cli/cli-review-commands.js +113 -0
  28. package/dist/src/cli/cli-role-command.js +118 -13
  29. package/dist/src/cli/cli-start-summary.js +10 -3
  30. package/dist/src/cli/cli-start.js +33 -5
  31. package/dist/src/cli/completion.js +21 -1
  32. package/dist/src/cli/config-commands.js +17 -8
  33. package/dist/src/cli/help.js +74 -14
  34. package/dist/src/cli/log-commands.js +26 -13
  35. package/dist/src/cli/question-commands.js +7 -1
  36. package/dist/src/cli.js +71 -6
  37. package/dist/src/collections.js +31 -4
  38. package/dist/src/config/config-editable-keys.js +3 -0
  39. package/dist/src/config/config-fallbacks.js +3 -2
  40. package/dist/src/config/config-validation.js +47 -42
  41. package/dist/src/config/config-views.js +10 -2
  42. package/dist/src/config/config-write.js +33 -1
  43. package/dist/src/config/config.js +7 -2
  44. package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
  45. package/dist/src/doctor/doctor-backlog-plans.js +214 -79
  46. package/dist/src/doctor/doctor-backlog.js +155 -22
  47. package/dist/src/doctor/doctor-check-names.js +5 -1
  48. package/dist/src/doctor/doctor-checks.js +10 -15
  49. package/dist/src/doctor/doctor-claude-checks.js +6 -9
  50. package/dist/src/doctor/doctor-codex-checks.js +6 -9
  51. package/dist/src/doctor/doctor-contention-checks.js +244 -118
  52. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  53. package/dist/src/doctor/doctor-holds.js +4 -6
  54. package/dist/src/doctor/doctor-model-checks.js +45 -200
  55. package/dist/src/doctor/doctor-notify.js +7 -2
  56. package/dist/src/doctor/doctor-opencode-checks.js +7 -5
  57. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  58. package/dist/src/doctor/doctor-project-checks.js +106 -12
  59. package/dist/src/doctor/doctor.js +10 -5
  60. package/dist/src/doctor/probe.js +14 -1
  61. package/dist/src/events/event-format.js +7 -6
  62. package/dist/src/events/event-labels.js +0 -1
  63. package/dist/src/events/event-read.js +13 -0
  64. package/dist/src/events/event-window.js +20 -2
  65. package/dist/src/failure/failure-data.js +9 -4
  66. package/dist/src/failure/failure-render.js +8 -8
  67. package/dist/src/failure/time-spend.js +0 -64
  68. package/dist/src/files/file-queue.js +17 -0
  69. package/dist/src/gates/budget-gates.js +27 -257
  70. package/dist/src/gates/gate-polls.js +49 -141
  71. package/dist/src/gates/maintenance-quota.js +27 -17
  72. package/dist/src/git/commit-message.js +12 -0
  73. package/dist/src/git/git.js +18 -0
  74. package/dist/src/git/slot-salvage.js +3 -3
  75. package/dist/src/gui/gui-backlog-commands.js +3 -3
  76. package/dist/src/gui/gui-backlog-endpoints.js +5 -4
  77. package/dist/src/gui/gui-command.js +19 -1
  78. package/dist/src/gui/gui-config-commands.js +75 -9
  79. package/dist/src/gui/gui-endpoint-commands.js +2 -2
  80. package/dist/src/gui/gui-operator-commands.js +134 -7
  81. package/dist/src/gui/gui-prompt-commands.js +15 -6
  82. package/dist/src/gui/gui-report-endpoints.js +10 -3
  83. package/dist/src/gui/gui-server.js +16 -2
  84. package/dist/src/history/history-queued.js +8 -0
  85. package/dist/src/history/history.js +2 -5
  86. package/dist/src/inbox/inbox-top.js +4 -9
  87. package/dist/src/merge/merge-core.js +2 -25
  88. package/dist/src/merge/merge-drain.js +22 -135
  89. package/dist/src/merge/merge-pipeline.js +1 -1
  90. package/dist/src/merge/merge-post-review.js +154 -30
  91. package/dist/src/merge/merge-queue-render.js +11 -12
  92. package/dist/src/merge/merge-queue.js +22 -1
  93. package/dist/src/merge/merge-requeue.js +1 -0
  94. package/dist/src/merge/merge-stack.js +1 -7
  95. package/dist/src/merge/merge-to-main.js +1 -9
  96. package/dist/src/merge/merge-top.js +33 -19
  97. package/dist/src/merge/merge-verify.js +64 -9
  98. package/dist/src/notes-cli.js +8 -3
  99. package/dist/src/orchestrator/orchestrator-poll.js +47 -15
  100. package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
  101. package/dist/src/orchestrator/orchestrator.js +5 -4
  102. package/dist/src/orchestrator/scheduling-scope.js +51 -7
  103. package/dist/src/orchestrator/scheduling-skip.js +15 -5
  104. package/dist/src/paths.js +16 -6
  105. package/dist/src/pi/pi-backend.js +2 -2
  106. package/dist/src/process/run-marker.js +8 -0
  107. package/dist/src/prompt/gate-notes.js +1 -1
  108. package/dist/src/prompt/gate-prompts.js +30 -7
  109. package/dist/src/prompt/notes-backlog.js +13 -10
  110. package/dist/src/prompt/prompt.js +3 -1
  111. package/dist/src/report/report-data.js +49 -69
  112. package/dist/src/report/report-fold.js +11 -12
  113. package/dist/src/report/report-render.js +6 -4
  114. package/dist/src/report/report.js +2 -5
  115. package/dist/src/review/diff-rules.js +53 -0
  116. package/dist/src/review/known-flakes.js +12 -0
  117. package/dist/src/review/post-merge-review-accept.js +51 -0
  118. package/dist/src/review/post-merge-review-render.js +112 -0
  119. package/dist/src/review/post-merge-review.js +157 -24
  120. package/dist/src/review/protected-edits.js +22 -7
  121. package/dist/src/review/quarantine-render.js +65 -0
  122. package/dist/src/review/review.js +33 -49
  123. package/dist/src/roles/role-catalog.js +60 -2
  124. package/dist/src/roles/role-guidance.js +10 -0
  125. package/dist/src/roles/role-labels.js +6 -3
  126. package/dist/src/roles/role-render.js +43 -8
  127. package/dist/src/roles/role-view.js +36 -9
  128. package/dist/src/roles/roles.js +3 -3
  129. package/dist/src/run/ci-parity.js +121 -0
  130. package/dist/src/run/qa-coverage.js +50 -15
  131. package/dist/src/run/run-detail-data.js +9 -13
  132. package/dist/src/run/run-detail-render.js +1 -8
  133. package/dist/src/run/run-finalize.js +1 -0
  134. package/dist/src/run/run-prompt.js +14 -0
  135. package/dist/src/run/run-stage.js +66 -37
  136. package/dist/src/run/run-verdict.js +14 -1
  137. package/dist/src/run/stage-check.js +17 -11
  138. package/dist/src/run/stage-checks-shared.js +65 -0
  139. package/dist/src/run/stage-name-checks.js +204 -0
  140. package/dist/src/run/stage-path-checks.js +16 -515
  141. package/dist/src/run/stage-prose-checks.js +261 -0
  142. package/dist/src/runner/leftover.js +50 -19
  143. package/dist/src/runner/revision.js +12 -23
  144. package/dist/src/runner/runner-outcome.js +4 -2
  145. package/dist/src/runner/runner-recovery.js +18 -3
  146. package/dist/src/runner/runner-run.js +21 -8
  147. package/dist/src/runner/runner-state.js +4 -3
  148. package/dist/src/scheduling/claim-entries.js +49 -0
  149. package/dist/src/status/status-data.js +6 -1
  150. package/dist/src/status/status-polls.js +15 -0
  151. package/dist/src/status/status-roles.js +11 -0
  152. package/dist/src/team/reclaim.js +2 -2
  153. package/dist/src/text/datetime.js +3 -2
  154. package/dist/src/text/phrases.js +6 -3
  155. package/dist/src/text/text.js +11 -0
  156. package/dist/src/ui/badges.js +34 -1
  157. package/dist/src/ui/gui/gui-client-backlog.js +25 -3
  158. package/dist/src/ui/gui/gui-client-boot.js +63 -41
  159. package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
  160. package/dist/src/ui/gui/gui-client-drawer.js +29 -4
  161. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  162. package/dist/src/ui/gui/gui-client-history.js +74 -72
  163. package/dist/src/ui/gui/gui-client-labels.js +5 -1
  164. package/dist/src/ui/gui/gui-client-operator.js +6 -2
  165. package/dist/src/ui/gui/gui-client-pending.js +188 -11
  166. package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
  167. package/dist/src/ui/gui/gui-client-report.js +20 -5
  168. package/dist/src/ui/gui/gui-client-settings.js +109 -3
  169. package/dist/src/ui/gui/gui-client-team.js +11 -0
  170. package/dist/src/ui/gui/gui-client.js +11 -1
  171. package/dist/src/ui/gui/gui-page.js +1 -0
  172. package/dist/src/ui/gui/gui-styles.js +4 -0
  173. package/dist/src/ui/run-progress-model.js +2 -1
  174. package/dist/src/ui/status-payload.js +237 -9
  175. package/dist/src/ui/status-render.js +12 -4
  176. package/dist/src/ui/team-alerts.js +512 -37
  177. package/dist/src/ui/tone.js +7 -0
  178. package/dist/src/ui/tui/tui-backlog.js +7 -5
  179. package/dist/src/ui/tui/tui-editor-attach.js +1 -7
  180. package/dist/src/ui/tui/tui-editor-backends.js +1 -6
  181. package/dist/src/ui/tui/tui-editor-brief.js +4 -24
  182. package/dist/src/ui/tui/tui-editor-budget.js +1 -8
  183. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  184. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  185. package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
  186. package/dist/src/ui/tui/tui-editor-config.js +19 -6
  187. package/dist/src/ui/tui/tui-editor-entry.js +3 -17
  188. package/dist/src/ui/tui/tui-editor-instances.js +9 -0
  189. package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
  190. package/dist/src/ui/tui/tui-editor-interval.js +18 -7
  191. package/dist/src/ui/tui/tui-editor-notes.js +6 -24
  192. package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
  193. package/dist/src/ui/tui/tui-editor-retire.js +1 -7
  194. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  195. package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
  196. package/dist/src/ui/tui/tui-editor-session.js +41 -0
  197. package/dist/src/ui/tui/tui-editor-slots.js +1 -0
  198. package/dist/src/ui/tui/tui-editors.js +10 -1
  199. package/dist/src/ui/tui/tui-frame.js +18 -6
  200. package/dist/src/ui/tui/tui-keys.js +9 -0
  201. package/dist/src/ui/tui/tui-pane-activity.js +21 -8
  202. package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
  203. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  204. package/dist/src/ui/tui/tui-pane-health.js +113 -3
  205. package/dist/src/ui/tui/tui-pane-history.js +44 -0
  206. package/dist/src/ui/tui/tui-pane-pending.js +285 -56
  207. package/dist/src/ui/tui/tui-pane-queued.js +7 -2
  208. package/dist/src/ui/tui/tui-pane-report.js +9 -3
  209. package/dist/src/ui/tui/tui-pane-session.js +9 -0
  210. package/dist/src/ui/tui/tui-pane.js +16 -10
  211. package/dist/src/ui/tui/tui-panes.js +56 -7
  212. package/dist/src/ui/tui/tui.js +8 -5
  213. package/dist/src/verdict/fix-claim.js +47 -1
  214. package/dist/src/verdict/reply-contract.js +35 -0
  215. package/package.json +1 -1
  216. package/dist/src/baseline/main-culprit.js +0 -94
  217. package/dist/src/baseline/main-revert.js +0 -133
@@ -261,6 +261,27 @@ export const PROBLEMS_FLAG = { names: ["--problems"] };
261
261
  /** The `--kill-orphans` flag spec, accepted by `doctor`: first kill the orphaned processes the
262
262
  * orphans check lists, each with its descendants. Value-less, like PROBLEMS_FLAG. */
263
263
  export const KILL_ORPHANS_FLAG = { names: ["--kill-orphans"] };
264
+ /** The `--commit-backlog` flag spec, accepted by `doctor`: first commit each dirty tracked backlog
265
+ * or brief file on the base branch before the report, under the primary-worktree lock, through the
266
+ * operator backlog commit path. The second mutating doctor flag, after `--kill-orphans`.
267
+ * Value-less, like KILL_ORPHANS_FLAG. */
268
+ export const COMMIT_BACKLOG_FLAG = { names: ["--commit-backlog"] };
269
+ /** The `--resync-install` flag spec, accepted by `doctor`: re-sync the root checkout's install
270
+ * on demand, under the merge lock, before the report (part 1/2; the Health surfaces that expose
271
+ * it are part 2/2). The third mutating doctor flag, after `--kill-orphans` and `--commit-backlog`.
272
+ * Value-less, like COMMIT_BACKLOG_FLAG. */
273
+ export const RESYNC_INSTALL_FLAG = { names: ["--resync-install"] };
274
+ /** The `--drop-broken-pins` flag spec, accepted by `doctor`: first drop each `refs/tumwater/`
275
+ * pin whose commit is gone — the manual `git update-ref -d` the `tumwater pins` row names. The
276
+ * fourth mutating doctor flag, after `--kill-orphans`, `--commit-backlog` and `--resync-install`.
277
+ * Value-less, like RESYNC_INSTALL_FLAG. */
278
+ export const DROP_BROKEN_PINS_FLAG = { names: ["--drop-broken-pins"] };
279
+ /** The `--retire-leftovers` flag spec, accepted by `doctor`: first retire each disabled role's
280
+ * leftover worktree, branch and merge ref in one pass — the reap for the `leftover role
281
+ * branches` row, through the same `retireRole` core `tumwater retire --role <id>` uses. The
282
+ * fifth mutating doctor flag, after `--kill-orphans`, `--commit-backlog`, `--resync-install` and
283
+ * `--drop-broken-pins`. Value-less, like DROP_BROKEN_PINS_FLAG. */
284
+ export const RETIRE_LEFTOVERS_FLAG = { names: ["--retire-leftovers"] };
264
285
  /** The `--stat` flag spec, accepted by `diff --role`: print each patch as its `git diff --stat`
265
286
  * summary. Value-less, like JSON_FLAG. */
266
287
  export const STAT_FLAG = { names: ["--stat"] };
@@ -292,6 +313,10 @@ export const SUMMARY_FLAG = { names: ["--summary"] };
292
313
  /** The `--count` flag spec, accepted by `logs`: tally the selected window's event types
293
314
  * instead of printing the rows. Value-less, like SUMMARY_FLAG. */
294
315
  export const COUNT_FLAG = { names: ["--count"] };
316
+ /** `logs --notable`: cull routine bookkeeping events the way the dashboard Activity feed's
317
+ * Notable choice does, keeping only non-routine rows. Value-less like JSON_FLAG and COUNT_FLAG,
318
+ * and a rival of the --transcript transcript view with the rest of the feed flags. */
319
+ export const NOTABLE_FLAG = { names: ["--notable"] };
295
320
  /** The `--force` flag spec, accepted by `retire` (the only override-the-rails command): one
296
321
  * definition of the flag's spelling beside ROLE_FLAG and JSON_FLAG, so the gate's accepted
297
322
  * vocabulary cannot drift from the command body's `args.includes("--force")`. */
@@ -328,6 +353,14 @@ export function typeFlagSpec(missingValue) {
328
353
  export function resultFlagSpec(missingValue) {
329
354
  return { names: ["--result"], value: true, valueName: "<class>", missingValue };
330
355
  }
356
+ /** The `--status <state>` flag spec of `reviews`' state filter, beside resultFlagSpec: one
357
+ * definition of the flag's spelling and value shape, so the gate's accepted vocabulary cannot
358
+ * drift. The missing-value wording names the command, so the spec is a factory taking it — the
359
+ * same string the body fails with; which states are accepted is the command body's rule
360
+ * (post-merge-review-render.ts's POST_REVIEW_STATES). */
361
+ export function statusFlagSpec(missingValue) {
362
+ return { names: ["--status"], value: true, valueName: "<state>", missingValue };
363
+ }
331
364
  /** `history --model <selector>[,...]`: keeps the runs that started on the listed models;
332
365
  * `missingValue` is the same string the command body fails with. */
333
366
  export function modelFlagSpec(missingValue) {
@@ -7,7 +7,7 @@ import { fail, say, sayJson, sayJsonOrRender } from "./cli-output.js";
7
7
  import { cancelQueuedMerge } from "../merge/merge-cancel.js";
8
8
  import { moveQueuedMergeToTop } from "../merge/merge-top.js";
9
9
  import { followView } from "./follow-view.js";
10
- import { flagValue, parseGrepFlag, parsePositionFlag, parseRoleListScope, rejectRepeatFlag } from "./cli-args.js";
10
+ import { flagValue, parseGrepFlag, parseRoleListScope, rejectRepeatFlag, scanPositionArgs } from "./cli-args.js";
11
11
  import { DIFF_PATH_FLAG, FOLLOW_FLAG, hasFollowFlag, JSON_FLAG, READY_FLAG, grepFlagSpec, rejectUnknownArgs, OVERLAPS_FLAG, ROLE_FLAG, STAT_FLAG } from "./cli-flag-specs.js";
12
12
  import { refuseTeamWorkspace, requireReadyRepo } from "./cli-repo-gate.js";
13
13
  import { scopeSnapshotToRoles, snapshot } from "../status/status-data.js";
@@ -16,6 +16,8 @@ import { renderStatus } from "../ui/status-render.js";
16
16
  import { backlogPayload } from "../backlog/backlog.js";
17
17
  import { backlogHolds, backlogInProgress } from "../backlog/backlog-eligibility.js";
18
18
  import { readyBacklog, grepBacklog, renderBacklogMarkdown } from "../backlog/backlog-render.js";
19
+ import { loadQuarantine } from "../review/known-flakes.js";
20
+ import { quarantineRows, renderQuarantine } from "../review/quarantine-render.js";
19
21
  import { collectChangeOverlaps, scopeChangeOverlaps, scopeChangeOverlapsToPath, collectTeamChanges, collectRoleChange, collectRoleChanges, normalizeDiffPath } from "../change/change-data.js";
20
22
  import { renderChangeOverlaps, renderTeamChange, renderRoleChange, renderRoleChanges } from "../change/change-render.js";
21
23
  import { readMergeQueue } from "../merge/merge-queue.js";
@@ -198,26 +200,7 @@ export async function cmdQueue(root, args, seams = {}) {
198
200
  * `tumwater queue`'s. An entry a live merge marker names for this sha is refused (wait for it
199
201
  * to land, then revert); an out-of-range position fails naming the current depth. */
200
202
  async function cmdQueueCancel(root, args) {
201
- let position = null;
202
- let json = false;
203
- const rest = [];
204
- for (let i = 0; i < args.length; i++) {
205
- const arg = args[i];
206
- if (arg === "--json") {
207
- rejectRepeatFlag(json, "--json");
208
- json = true;
209
- continue;
210
- }
211
- if (arg === "--cancel") {
212
- rejectRepeatFlag(position !== null, "--cancel");
213
- position = parsePositionFlag("--cancel", args[i + 1], "--cancel 2");
214
- i++;
215
- continue;
216
- }
217
- if (arg === "--help" || arg === "-h")
218
- continue;
219
- rest.push(arg);
220
- }
203
+ const { json, position, rest } = scanPositionArgs(args, "--cancel", "--cancel 2");
221
204
  // The vocabulary a cancel invocation accepts is exactly --cancel and --json, both consumed
222
205
  // above; --role and -f/--follow are refused with the queue-specific wording the tests pin.
223
206
  if (rest.includes("--role"))
@@ -259,26 +242,7 @@ async function cmdQueueCancel(root, args) {
259
242
  * out-of-range position fails naming the current depth; position 1 prints the already-first
260
243
  * line and exits 0; a concurrently-drained entry prints the gone line and exits 0. */
261
244
  async function cmdQueueTop(root, args) {
262
- let position = null;
263
- let json = false;
264
- const rest = [];
265
- for (let i = 0; i < args.length; i++) {
266
- const arg = args[i];
267
- if (arg === "--json") {
268
- rejectRepeatFlag(json, "--json");
269
- json = true;
270
- continue;
271
- }
272
- if (arg === "--top") {
273
- rejectRepeatFlag(position !== null, "--top");
274
- position = parsePositionFlag("--top", args[i + 1], "--top 2");
275
- i++;
276
- continue;
277
- }
278
- if (arg === "--help" || arg === "-h")
279
- continue;
280
- rest.push(arg);
281
- }
245
+ const { json, position, rest } = scanPositionArgs(args, "--top", "--top 2");
282
246
  // The vocabulary a top invocation accepts is exactly --top and --json, both consumed
283
247
  // above; --role and -f/--follow are refused with the queue-specific wording the tests pin.
284
248
  if (rest.includes("--role"))
@@ -374,3 +338,14 @@ export async function cmdBacklog(root, args, seams = {}) {
374
338
  const result = readBacklog();
375
339
  sayJsonOrRender(rest, result.view, () => renderBacklogText(result));
376
340
  }
341
+ // `tumwater quarantine [--json]` — the read-only view of the review gate's flake
342
+ // quarantine. No requireReadyRepo gate: loadQuarantine degrades an absent or malformed
343
+ // file to the empty list (the cmdBacklog rationale), so the command prints the empty line
344
+ // in any directory, and it never writes quarantine.json (a test leaves the list only
345
+ // through releaseQuarantine). --json serves the same positioned rows to scripts.
346
+ export async function cmdQuarantine(root, args) {
347
+ rejectUnknownArgs("quarantine", args, [JSON_FLAG]);
348
+ refuseTeamWorkspace(root);
349
+ const rows = quarantineRows(loadQuarantine(root));
350
+ sayJsonOrRender(args, { tests: rows }, () => renderQuarantine(rows, Date.now()));
351
+ }
@@ -0,0 +1,113 @@
1
+ /** `tumwater reviews` — the operator-facing twin of `tumwater queue` for the post-merge review
2
+ * queue (.tumwater/state/post-review.json): list every well-formed entry in FIFO order, or the
3
+ * same list as JSON (`--json`). It does not require a ready repo (the `cmdBacklog` rationale):
4
+ * `readPostReviews` reads a missing, torn or hand-edited file as the well-formed subset (or `[]`),
5
+ * so the command prints the empty line in any directory. Read-only except the one destructive
6
+ * `--accept <n>` mode, which removes the Nth entry the operator deliberately accepts unreviewed
7
+ * (the action the at-risk alert's advice names); that mode adds no new ready-repo requirement to
8
+ * the read path and gates the write with `requireReadyRepo`. Unlike `cmdQueue`'s `--cancel`, the
9
+ * read case still writes no file. */
10
+ import { JSON_FLAG, ROLE_FLAG, rejectUnknownArgs, statusFlagSpec } from "./cli-flag-specs.js";
11
+ import { sayJsonOrRender, say, sayJson, fail } from "./cli-output.js";
12
+ import { refuseTeamWorkspace, requireReadyRepo } from "./cli-repo-gate.js";
13
+ import { parseRoleListScope, parseStatusFlag, scanPositionArgs } from "./cli-args.js";
14
+ import { readPostReviews } from "../review/post-merge-review.js";
15
+ import { acceptPostReview } from "../review/post-merge-review-accept.js";
16
+ import { POST_REVIEW_STATES, postReviewState, renderPostReviews, scopePostReviews, } from "../review/post-merge-review-render.js";
17
+ import { roleInScope } from "../roles/runner-ids.js";
18
+ import { roleLabel } from "../roles/role-labels.js";
19
+ import { shortSha } from "../text/format.js";
20
+ import { gotSuffix, splitCommaList } from "../text/text.js";
21
+ import { typoSuffix } from "../text/suggest.js";
22
+ /** The missing-value wording of `reviews --status` (shared by the gate's statusFlagSpec and
23
+ * parseStatusFlag's own fail, so the two cannot drift). */
24
+ const REVIEWS_STATUS_VALUE_ERROR = "reviews --status needs a value";
25
+ /** Parse a `--status` value: a comma list of post-merge review states, deduplicated in
26
+ * first-seen order, accepting only `POST_REVIEW_STATES` members. The first unknown piece fails
27
+ * naming the accepted states, mirroring parseHistoryResultList's gotSuffix/typoSuffix shape. */
28
+ function parseReviewStatusList(raw) {
29
+ if (raw === "")
30
+ fail(REVIEWS_STATUS_VALUE_ERROR);
31
+ const pieces = splitCommaList(raw);
32
+ if (pieces.length === 0)
33
+ fail(REVIEWS_STATUS_VALUE_ERROR);
34
+ const spellings = [...POST_REVIEW_STATES];
35
+ const out = [];
36
+ for (const piece of pieces) {
37
+ if (!POST_REVIEW_STATES.includes(piece))
38
+ fail(`reviews --status must be one of ${spellings.join(", ")}${gotSuffix(piece)}${typoSuffix(piece, spellings)}`);
39
+ if (!out.includes(piece))
40
+ out.push(piece);
41
+ }
42
+ return out;
43
+ }
44
+ /** `tumwater reviews [--json]`: the post-merge review queue, oldest first.*/
45
+ export async function cmdReviews(root, args) {
46
+ // `tumwater reviews --accept <n>` is the read-only command's one destructive mode: route it
47
+ // to its own branch before the read gate, so --accept never leaks into the view parsing.
48
+ if (args.includes("--accept")) {
49
+ await cmdReviewsAccept(root, args);
50
+ return;
51
+ }
52
+ rejectUnknownArgs("reviews", args, [ROLE_FLAG, statusFlagSpec(REVIEWS_STATUS_VALUE_ERROR), JSON_FLAG]);
53
+ refuseTeamWorkspace(root);
54
+ // `--role` keeps only the listed roles' entries (a base id taking its instances too) under
55
+ // their whole-queue positions, so `--accept <n>` still addresses the position the unscoped
56
+ // read prints. Absent a ready config `parseRoleListScope` falls back to the built-in catalog.
57
+ const roles = parseRoleListScope(root, args);
58
+ const { raw: statusRaw } = parseStatusFlag(args, REVIEWS_STATUS_VALUE_ERROR);
59
+ const whole = readPostReviews(root);
60
+ let entries = whole;
61
+ let scope;
62
+ if (statusRaw === null) {
63
+ const scoped = roles ? scopePostReviews(whole, roles, (e) => roleInScope(e.role, roles)) : undefined;
64
+ entries = scoped ? scoped.entries : whole;
65
+ scope = scoped?.scope;
66
+ }
67
+ else {
68
+ // `--status <state>[,...]` keeps only the listed states (the same precedence
69
+ // postReviewStatusLabel prints: a rejected-and-failing entry is rejected) under their
70
+ // whole-queue positions, composing with `--role`. roles=[] when unscoped.
71
+ const states = parseReviewStatusList(statusRaw);
72
+ const keep = (e) => (!roles || roleInScope(e.role, roles)) && states.includes(postReviewState(e));
73
+ const filterText = [roles ? `--role ${roles.join(",")}` : "", `--status ${states.join(",")}`]
74
+ .filter(Boolean)
75
+ .join(" ");
76
+ const scoped = scopePostReviews(whole, roles ?? [], keep, filterText);
77
+ entries = scoped.entries;
78
+ scope = scoped.scope;
79
+ }
80
+ sayJsonOrRender(args, { pending: entries }, () => renderPostReviews(entries, scope, Date.now()));
81
+ }
82
+ /** The `tumwater reviews --accept <n>` branch (PLANS.md): remove the Nth pending post-merge
83
+ * review `tumwater reviews` lists, accepting the commit unreviewed at the operator's request.
84
+ * Parsed the way `cmdQueueCancel` parses `--cancel`: `--accept <n>` the one valued flag,
85
+ * `--json` the one boolean (at most once), `--help`/`-h` tolerated, anything else refused. An
86
+ * out-of-range position fails naming the current depth; an entry whose repair already merged
87
+ * (`fixed`) is refused. A rejected entry waits on nothing and is accepted like any pending one. */
88
+ async function cmdReviewsAccept(root, args) {
89
+ const { json, position, rest } = scanPositionArgs(args, "--accept", "--accept 2");
90
+ // The vocabulary an accept invocation accepts is exactly --accept and --json, both consumed
91
+ // above; --role is refused by name (as cmdQueueCancel refuses it): accept addresses a
92
+ // whole-queue position `tumwater reviews` printed, and a scoped read must never renumber it.
93
+ if (rest.includes("--role") || rest.includes("--status"))
94
+ fail("reviews --accept accepts by whole-queue position from `tumwater reviews`; --role and --status scope a read only — drop them");
95
+ // Everything else is refused with the generic wording, so `--bogus` is named.
96
+ rejectUnknownArgs("reviews", rest, []);
97
+ if (position === null)
98
+ fail("--accept needs a position (usage: tumwater reviews --accept <n>)");
99
+ await requireReadyRepo(root);
100
+ const outcome = acceptPostReview(root, position);
101
+ if (outcome.status === "out-of-range") {
102
+ fail(`no post-merge review at position ${position} (post-merge review queue holds ${outcome.depth})`);
103
+ }
104
+ if (outcome.status === "settled") {
105
+ fail(`post-merge review ${position} already merged its fix on main — nothing to accept`);
106
+ }
107
+ if (json) {
108
+ sayJson({ accepted: { role: outcome.role, commit: outcome.commit, summary: outcome.summary } });
109
+ }
110
+ else {
111
+ say(`accepted post-merge review entry ${position} unreviewed: ${roleLabel(outcome.role)} ${shortSha(outcome.commit)} — ${outcome.summary}`);
112
+ }
113
+ }
@@ -2,9 +2,9 @@
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
4
  * config-commands. cmdRole is the one query-named command that also writes: it renders a
5
- * role's standing and next-run prompts and carries ten per-role write flags (notes,
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
- * provider/model/thinking triple), each through the same setConfigKey/unsetConfigKey core
7
+ * provider/model/thinking/backend), each through the same setConfigKey/unsetConfigKey core
8
8
  * `config set`/`config unset` use. It lives in its own file because its mixed
9
9
  * positional/flag vocabulary and its write branches are its own, not the query commands'. */
10
10
  import { fail, say, sayJsonOrRender } from "./cli-output.js";
@@ -44,6 +44,12 @@ const CAP_SPEC = { names: ["--cap"], value: true, valueName: "<usd>" };
44
44
  function plainDecimalToJson(raw) {
45
45
  return /^\d*\.?\d+$/.test(raw) ? String(Number(raw)) : null;
46
46
  }
47
+ /** Detect exponent spellings that the cap, interval and instances writes must reject
48
+ * before JSON parsing can turn them into otherwise valid numeric config values. Other
49
+ * invalid spellings fall through to the config validator, which names the key. */
50
+ function isExponentSpelling(raw) {
51
+ return raw.trim() !== "" && plainDecimalToJson(raw) === null && /^\d+(\.\d+)?[eE][+-]?\d+$/.test(raw.trim());
52
+ }
47
53
  /** `role`'s min-run-interval write flags: clear it, or set it to a second count (0 is
48
54
  * back-to-back). */
49
55
  const CLEAR_MIN_RUN_INTERVAL_SPEC = { names: ["--clear-min-run-interval"] };
@@ -56,6 +62,10 @@ const INSTANCES_SPEC = { names: ["--instances"], value: true, valueName: "<n>" }
56
62
  * roles.<base>.enabled. */
57
63
  const ENABLE_SPEC = { names: ["--enable"] };
58
64
  const DISABLE_SPEC = { names: ["--disable"] };
65
+ /** `role`'s review-after-merge write flags: add the role to, or remove it from,
66
+ * review.afterMerge (written for the base id). */
67
+ const REVIEW_AFTER_MERGE_SPEC = { names: ["--review-after-merge"] };
68
+ const CLEAR_REVIEW_AFTER_MERGE_SPEC = { names: ["--clear-review-after-merge"] };
59
69
  /** `role`'s model-triple write flags: clear the field, or set it, written to
60
70
  * roles.<base>.provider/.model/.thinking. model and provider take an id; thinking takes a
61
71
  * pi `--thinking` level (off|minimal|low|medium|high|xhigh|max). */
@@ -65,18 +75,21 @@ const CLEAR_MODEL_SPEC = { names: ["--clear-model"] };
65
75
  const MODEL_SPEC = { names: ["--model"], value: true, valueName: "<id>" };
66
76
  const CLEAR_THINKING_SPEC = { names: ["--clear-thinking"] };
67
77
  const THINKING_SPEC = { names: ["--thinking"], value: true, valueName: "<level>" };
78
+ const CLEAR_BACKEND_SPEC = { names: ["--clear-backend"] };
79
+ const BACKEND_SPEC = { names: ["--backend"], value: true, valueName: "<id>" };
68
80
  /** Peel `tumwater role <id>`'s positional id off the argument list: the first token that is
69
81
  * neither a flag nor the value of a --role, --notes-file, --instructions-file,
70
- * --quiet-hours, --cap, --min-run-interval, --instances, --provider, --model or
71
- * --thinking flag. Returns the id (null when absent) and the remaining arguments — flags
72
- * only, ready for rejectUnknownArgs and sayJsonOrRender. The id may also arrive as
82
+ * --quiet-hours, --cap, --min-run-interval, --instances, --provider, --model,
83
+ * --thinking or --backend flag. Returns the id (null when absent) and the remaining
84
+ * arguments — flags only, ready for rejectUnknownArgs and sayJsonOrRender. The id may
85
+ * also arrive as
73
86
  * --role <id>, the flag spelling every other role-targeting command shares; the
74
87
  * collector's caller rejects both spellings at once rather than silently picking one.
75
88
  * Lives here because it exists only for this one
76
89
  * command's mixed positional/flag vocabulary. */
77
90
  function peelRolePositional(args) {
78
91
  const valueIdx = new Set();
79
- for (const flag of ["--role", "--notes-file", "--instructions-file", "--quiet-hours", "--cap", "--min-run-interval", "--instances", "--provider", "--model", "--thinking"]) {
92
+ for (const flag of ["--role", "--notes-file", "--instructions-file", "--quiet-hours", "--cap", "--min-run-interval", "--instances", "--provider", "--model", "--thinking", "--backend"]) {
80
93
  const at = args.indexOf(flag);
81
94
  if (at >= 0)
82
95
  valueIdx.add(at + 1);
@@ -106,9 +119,10 @@ function requireKnownRole(root, id) {
106
119
  if (!knownRoleIds(cfg).includes(id))
107
120
  throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
108
121
  }
109
- /** The config-key write the six per-role hold branches (quiet hours, daily cap, min run
110
- * interval, provider, model and thinking) share: with `clear`, or an empty typed value, unset
111
- * `key` under `base`; else set it. `serialize` shapes the value `setConfigKey` stores (it
122
+ /** The config-key write the seven per-role hold branches (quiet hours, daily cap, min run
123
+ * interval, provider, model, thinking and backend) share: with `clear`, or an empty typed
124
+ * value, unset `key` under `base`; else set it. `serialize` shapes the value `setConfigKey`
125
+ * stores (it
112
126
  * JSON-encodes nothing itself) and `display` gives the human phrase for the confirmation
113
127
  * line. The caller returns right after. The instructions branch keeps its own block: its
114
128
  * value is a file's text, so an empty value is a legitimate write, not a clear. A no-op (a
@@ -148,13 +162,13 @@ export async function cmdRole(root, args) {
148
162
  // Only a `--` (double-dash) token reads as a flag: a single-dash token like `-1` is a
149
163
  // negative-number value that must reach the setters' own bound checks, and the file flags'
150
164
  // `-` stdin value is not in this set at all.
151
- for (const flag of ["--provider", "--model", "--thinking", "--cap", "--min-run-interval", "--instances", "--quiet-hours"]) {
165
+ for (const flag of ["--provider", "--model", "--thinking", "--backend", "--cap", "--min-run-interval", "--instances", "--quiet-hours"]) {
152
166
  const at = rest.indexOf(flag);
153
167
  const next = at >= 0 ? rest[at + 1] : undefined;
154
168
  if (next?.startsWith("--"))
155
169
  fail(`${flag} needs a value (the next token ${JSON.stringify(next)} is a flag, not a value)`);
156
170
  }
157
- 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]);
171
+ 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]);
158
172
  refuseTeamWorkspace(root);
159
173
  // The id is required: positional (`tumwater role <id>`) or --role <id> (the flag
160
174
  // spelling every other role-targeting command shares). Both at once is a mistake, not
@@ -177,6 +191,7 @@ export async function cmdRole(root, args) {
177
191
  const clearProvider = rest.includes("--clear-provider");
178
192
  const clearModel = rest.includes("--clear-model");
179
193
  const clearThinking = rest.includes("--clear-thinking");
194
+ const clearBackend = rest.includes("--clear-backend");
180
195
  const notesWrite = clear || rest.includes("--notes-file");
181
196
  const instructionsWrite = clearInst || rest.includes("--instructions-file");
182
197
  const quietWrite = clearQuiet || rest.includes("--quiet-hours");
@@ -186,8 +201,12 @@ export async function cmdRole(root, args) {
186
201
  const providerWrite = clearProvider || rest.includes("--provider");
187
202
  const modelWrite = clearModel || rest.includes("--model");
188
203
  const thinkingWrite = clearThinking || rest.includes("--thinking");
204
+ const backendWrite = clearBackend || rest.includes("--backend");
189
205
  const enable = rest.includes("--enable");
190
206
  const disable = rest.includes("--disable");
207
+ const reviewAfterMerge = rest.includes("--review-after-merge");
208
+ const clearReviewAfterMerge = rest.includes("--clear-review-after-merge");
209
+ const reviewAfterMergeWrite = reviewAfterMerge || clearReviewAfterMerge;
191
210
  const writes = [];
192
211
  if (notesWrite)
193
212
  writes.push("notes");
@@ -207,10 +226,14 @@ export async function cmdRole(root, args) {
207
226
  writes.push("model");
208
227
  if (thinkingWrite)
209
228
  writes.push("thinking");
229
+ if (backendWrite)
230
+ writes.push("backend");
210
231
  if (enable)
211
232
  writes.push("enable");
212
233
  if (disable)
213
234
  writes.push("disable");
235
+ if (reviewAfterMergeWrite)
236
+ writes.push("review-after-merge");
214
237
  if (writes.length > 1)
215
238
  fail(`${writes.join(" and ")} writes cannot share one invocation`);
216
239
  if (notesWrite) {
@@ -292,8 +315,20 @@ export async function cmdRole(root, args) {
292
315
  if (id === DIRECTOR_ROLE)
293
316
  fail("the director has no daily cap");
294
317
  const base = baseRoleOf(id);
318
+ const cfgKey = `maxDailyCostUsdPerRole.${base}`;
295
319
  const cap = flagValue(rest, "--cap") ?? "";
296
- writeRoleConfigKey(root, base, clearCap, cap, `maxDailyCostUsdPerRole.${base}`, "daily cap", (v) => plainDecimalToJson(v) ?? v, (v) => v);
320
+ // Shape, before the write: a spelling that is not a plain decimal but that JSON.parse
321
+ // would still read as a non-negative USD amount (an exponent like "1e2" → 100) would
322
+ // otherwise sail past both plainDecimalToJson (which only rewrites JSON-unparseable
323
+ // spellings like `05`) and checkDailyBudgetUsd's non-negative check and silently write
324
+ // the misread — the same silent misread the TUI Ctrl+B budget editor and the GUI cap
325
+ // drawer now reject. Every non-exponent spelling JSON reads as a non-negative USD is
326
+ // already a plain decimal, so the exponent form is exactly the silent-write set; hex,
327
+ // negative and non-numeric spellings fall through to the write path, whose
328
+ // validateConfig message names the key.
329
+ if (isExponentSpelling(cap))
330
+ fail(`${cfgKey} must be a plain dollar amount like 25, 12.50, or .5 — no +/-, exponent, or hex notation`);
331
+ writeRoleConfigKey(root, base, clearCap, cap, cfgKey, "daily cap", (v) => plainDecimalToJson(v) ?? v, (v) => v);
297
332
  return;
298
333
  }
299
334
  // The min-run-interval write: the terminal twin of `config set
@@ -310,8 +345,20 @@ export async function cmdRole(root, args) {
310
345
  fail("--json does not apply to a min-run-interval write");
311
346
  requireKnownRole(root, id);
312
347
  const base = baseRoleOf(id);
348
+ const cfgKey = `roles.${base}.minRunIntervalSeconds`;
313
349
  const secs = flagValue(rest, "--min-run-interval") ?? "";
314
- writeRoleConfigKey(root, base, clearInterval, secs, `roles.${base}.minRunIntervalSeconds`, "min run interval", (v) => plainDecimalToJson(v) ?? v, (v) => `${v}s`);
350
+ // Shape, before the write: a spelling that is not a plain decimal but that JSON.parse
351
+ // would still read as a non-negative second count (an exponent like "1e2" → 100) would
352
+ // otherwise sail past both plainDecimalToJson (which only rewrites JSON-unparseable
353
+ // spellings like `05`) and validateConfig's non-negative check and silently write the
354
+ // misread — the same silent misread the TUI Ctrl+S editor and the GUI interval drawer
355
+ // now reject. Every non-exponent spelling JSON reads as a non-negative count is already a
356
+ // plain decimal, so the exponent form is exactly the silent-write set; hex, negative and
357
+ // non-numeric spellings fall through to the write path, whose validateConfig message
358
+ // names the key.
359
+ if (isExponentSpelling(secs))
360
+ fail(`${cfgKey} must be a plain second count like 300, 0.5, or .5 — no +/-, exponent, or hex notation`);
361
+ writeRoleConfigKey(root, base, clearInterval, secs, cfgKey, "min run interval", (v) => plainDecimalToJson(v) ?? v, (v) => `${v}s`);
315
362
  return;
316
363
  }
317
364
  // The instances write: the terminal twin of `config set roles.<base>.instances <n>`, the
@@ -331,7 +378,19 @@ export async function cmdRole(root, args) {
331
378
  if (!knownRoleIds(cfg).includes(id))
332
379
  throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
333
380
  const base = baseRoleOf(id);
381
+ const cfgKey = `roles.${base}.instances`;
334
382
  const n = flagValue(rest, "--instances") ?? "";
383
+ // Shape, before the write: a spelling that is not a plain integer but that JSON.parse
384
+ // would still read as a 1–8 count (an exponent like "2e0" → 2) would otherwise sail past
385
+ // both plainDecimalToJson and validateConfig's INSTANCE_COUNT integer check and silently
386
+ // write the misread — the same silent misread the cap and min-run-interval writes refuse.
387
+ // Every non-exponent spelling that JSON reads as an integer in range is already a plain
388
+ // integer, so the exponent form is exactly the silent-write set; every exponent — in range
389
+ // or out ("9e0" → 9 too) — is refused here, while fractional, hex, negative and plain
390
+ // out-of-range integer spellings fall through to the write path, whose validateConfig
391
+ // message names the key.
392
+ if (isExponentSpelling(n))
393
+ fail(`${cfgKey} must be a plain integer from 1 to 8 — no exponent notation`);
335
394
  // An absent count already means 1 (instanceCount's default), so asking for 1 is a no-op —
336
395
  // but only for a role that may split; any other role falls through to validateConfig's refusal.
337
396
  // A tumwater.json that fails to load is no basis for "already": fall through to the failure.
@@ -387,6 +446,22 @@ export async function cmdRole(root, args) {
387
446
  writeRoleConfigKey(root, base, clearThinking, level, `roles.${base}.thinking`, "thinking", (v) => JSON.stringify(v), (v) => v);
388
447
  return;
389
448
  }
449
+ // The backend write: the twin of the provider/model/thinking writes, landing the role's
450
+ // backend (`pi`, `claude`, `opencode` or `codex`) in roles.<base>.backend through the same
451
+ // setConfigKey/unsetConfigKey core, so validateConfig's checkEntryBackend owns the id
452
+ // (rejecting an unknown one, and a `model` that cannot pair with the named backend) and
453
+ // the write stays atomic. An instance id writes its base role's key, like the others.
454
+ if (backendWrite) {
455
+ if (clearBackend && rest.includes("--backend"))
456
+ fail("give --clear-backend or --backend, not both");
457
+ if (rest.includes("--json"))
458
+ fail("--json does not apply to a backend write");
459
+ requireKnownRole(root, id);
460
+ const base = baseRoleOf(id);
461
+ const backend = flagValue(rest, "--backend") ?? "";
462
+ writeRoleConfigKey(root, base, clearBackend, backend, `roles.${base}.backend`, "backend", (v) => JSON.stringify(v), (v) => v);
463
+ return;
464
+ }
390
465
  // The enable/disable write: the scoped write for the enabled state `tumwater role <id>`
391
466
  // already renders, the terminal twin of `config set roles.<base>.enabled true|false`. The
392
467
  // value lands in roles.<base>.enabled through the same setConfigKey core config set uses, so
@@ -414,6 +489,36 @@ export async function cmdRole(root, args) {
414
489
  say(`${base}: ${state}`);
415
490
  return;
416
491
  }
492
+ // The review-after-merge write: the one command the post-merge churn alert's remedy
493
+ // needs — add the role to, or remove it from, review.afterMerge for the base id, the
494
+ // membership test reviewedAfterMerge shares. The whole-list value lands through the same
495
+ // setConfigKey core `config set review.afterMerge <json>` uses, so validateConfig owns the
496
+ // string-array shape, the known-role-id check and the `review.afterMerge must not be
497
+ // "director"` refusal, and the write is atomic. A role already in the asked-for state is
498
+ // reported as such and the file is left alone (the section branch compares the value
499
+ // against the resolved list, so a no-op reports unchanged and writes nothing).
500
+ if (reviewAfterMergeWrite) {
501
+ if (reviewAfterMerge && clearReviewAfterMerge)
502
+ fail("give --review-after-merge or --clear-review-after-merge, not both");
503
+ if (rest.includes("--json"))
504
+ fail("--json does not apply to a review-after-merge write");
505
+ requireKnownRole(root, id);
506
+ const base = baseRoleOf(id);
507
+ const list = loadConfigSafe(root).config?.review.afterMerge ?? [];
508
+ const has = list.includes(base);
509
+ const next = has === reviewAfterMerge ? list : reviewAfterMerge ? [...list, base] : list.filter((r) => r !== base);
510
+ const result = setConfigKey(root, "review.afterMerge", JSON.stringify(next));
511
+ if (!result.ok)
512
+ fail(result.error);
513
+ say(result.unchanged
514
+ ? reviewAfterMerge
515
+ ? `${base}: already in review.afterMerge, so tumwater.json was not written`
516
+ : `${base}: not in review.afterMerge, so nothing was cleared`
517
+ : reviewAfterMerge
518
+ ? `${base}: added to review.afterMerge`
519
+ : `${base}: removed from review.afterMerge`);
520
+ return;
521
+ }
417
522
  // The unknown-role answer (exit 1, unknownRoleMessage's wording) lives in the
418
523
  // collector; here the id just has to be non-null for the thunk's types.
419
524
  sayJsonOrRender(rest, () => rolePayload(root, id), renderRoleMarkdown);
@@ -11,16 +11,23 @@ import { plural } from "../text/phrases.js";
11
11
  import { SPARSE_WINDOW_NOTE } from "../events/event-window.js";
12
12
  import { renderSummary } from "../history/history-render.js";
13
13
  import { failOverDurationCap, failRivalShapes, flagValue, parseDurationFlag, } from "./cli-args.js";
14
+ import { parsePositiveInt } from "../text/text.js";
14
15
  import { PAUSE_FOR_MAX_MS } from "../operator/operator-intent.js";
15
16
  import { loadRunnerState } from "../runner/runner-state-load.js";
16
17
  import { stateSkipReason } from "../runner/runner-state.js";
17
18
  import { isTeamPaused, pausedRoles } from "../team/team-state.js";
18
19
  import { runnerPaused } from "../roles/runner-ids.js";
19
20
  /** The window start a generation adopts: the supervisor's RUN_WINDOW_START_ENV mark when it is a
20
- * positive integer no later than `now`, else `now` (absent, malformed, or in the future). */
21
+ * positive integer no later than `now`, else `now` (absent, malformed, or in the future). The
22
+ * mark is read through the shared plain-decimal rule (parsePositiveInt) rather than bare
23
+ * Number(), which would silently coerce a hex/exponent/signed spelling ("0x10" → 16, "1e3" →
24
+ * 1000) into a window start the supervisor never wrote — the same plain-decimal discipline the
25
+ * CLI count/position flags apply to every other string boundary. */
21
26
  export function inheritedWindowStart(raw, now) {
22
- const n = Number(raw);
23
- return raw !== undefined && Number.isInteger(n) && n > 0 && n <= now ? n : now;
27
+ if (raw === undefined)
28
+ return now;
29
+ const n = parsePositiveInt(raw);
30
+ return n !== null && n <= now ? n : now;
24
31
  }
25
32
  /** The `run --for` exit summary: a window line, then the `history --summary` table for the runs
26
33
  * that ended since the window opened — every run counted once by its outcome class. Only the
@@ -16,6 +16,13 @@ import { loadRunnerState } from "../runner/runner-state-load.js";
16
16
  import { shortSha } from "../text/format.js";
17
17
  import { readRunRowsSince, summarizeRunRows } from "../history/history-data.js";
18
18
  import { ONCE_FAILURE_RESULTS, inheritedWindowStart, onceFailures, onceReport, onceSummary, parseRunWindow, renderOnceSummary, windowReport, windowSummary, } from "./cli-start-summary.js";
19
+ /** The env var that carries the dashboard gate token between a supervisor and its dashboard
20
+ * child (and across a re-exec'd dashboard generation), for the `run --gui`/`start --gui`
21
+ * supervisor's own use. Defined here rather than imported from gui-command.js so this module
22
+ * (and every `start`/`run` invocation) never pulls the GUI server graph onto the page; it
23
+ * must stay the same literal as gui-command's GUI_TOKEN_ENV, and a merge that renames one
24
+ * renames the other. */
25
+ const GUI_TOKEN_ENV = "TUMWATER_GUI_TOKEN";
19
26
  export { ONCE_FAILURE_RESULTS, inheritedWindowStart, onceFailures, onceReport, onceSummary, parseRunWindow, renderOnceSummary, windowReport, windowSummary, };
20
27
  /** `tumwater start`: boot the team and stream its event feed to this terminal until it stops.
21
28
  * Three layers cooperate here: without SUPERVISED_ENV this process becomes the supervisor
@@ -64,6 +71,17 @@ export async function cmdStart(root, args) {
64
71
  const tokenProblem = tokenValueProblem(args, "tumwater start --gui");
65
72
  if (tokenProblem)
66
73
  fail(tokenProblem);
74
+ // The supervisor (this process, when run without SUPERVISED_ENV) is long-lived and the OS
75
+ // process table (ps, /proc/<pid>/cmdline) is world-readable, so an operator-typed secret must
76
+ // not sit in this process's argv for the whole run. guiChildPlan has already extracted the
77
+ // token into `plan.guiToken` for the dashboard child's env (see superviseDashboard), so take
78
+ // `--token <value>` off the command line here — the env rides every spawn and reexecSelf
79
+ // re-run, so no dashboard child or reloaded generation loses the gate — and say it is gone.
80
+ const argvToken = process.argv.indexOf("--token");
81
+ if (argvToken >= 0) {
82
+ process.argv.splice(argvToken, 2);
83
+ say(`removed --token from this process's command line; prefer the ${GUI_TOKEN_ENV} env var`);
84
+ }
67
85
  const forMs = parseRunWindow(args, once);
68
86
  const branchArg = parseBranchFlag(args);
69
87
  const startup = await runStartupCheck(root, branchArg);
@@ -328,12 +346,16 @@ export function guiChildPlan(runArgs) {
328
346
  guiArgs.push("--port", String(port));
329
347
  if (runArgs.includes("--all-interfaces"))
330
348
  guiArgs.push("--all-interfaces");
331
- if (tokenRaw !== undefined)
332
- guiArgs.push("--token", tokenRaw);
349
+ // The token is returned separately (guiToken) and reaches the child through its env, never
350
+ // guiArgs/argv: a secret on the command line is world-readable (/proc/<pid>/cmdline) and is
351
+ // re-run verbatim by a self-reload onto each new generation. The skip set still drops the
352
+ // operator's --token pair from the orchestrator generation (its cmdStart would refuse a
353
+ // dashboard flag without --gui, and a self-redeploy hand-off must never forward it).
333
354
  return {
334
355
  spawnGui: runArgs.includes("--gui"),
335
356
  spawnTui: runArgs.includes("--tui"),
336
357
  guiArgs,
358
+ guiToken: tokenRaw,
337
359
  orchestratorArgs: runArgs.filter((arg, i) => arg !== "--gui" && arg !== "--tui" && arg !== "--all-interfaces" && !skip.has(i)),
338
360
  childStdio: runArgs.includes("--tui") ? ["ignore", "ignore", "ignore"] : "inherit",
339
361
  };
@@ -344,11 +366,17 @@ export function guiChildPlan(runArgs) {
344
366
  * redeploy and exits on its own if this supervisor dies outright (SIGKILL, a crash) — a killed
345
367
  * supervisor never leaves a dashboard holding its port. Any other dashboard exit (a failed bind
346
368
  * prints its own message and exits) ends this loop and leaves the team running. */
347
- async function superviseDashboard(signal, stdio = "inherit", guiArgs = []) {
369
+ async function superviseDashboard(signal, guiToken, stdio = "inherit", guiArgs = []) {
348
370
  const { DASHBOARD_CHILD_ENV } = await import("../redeploy/self-reload.js");
349
371
  const { spawnGuiChild } = await import("../process/supervisor.js");
372
+ const env = { [DASHBOARD_CHILD_ENV]: String(process.pid) };
373
+ // The gate token rides in the child's env, not its argv: /proc/<pid>/cmdline is world-readable,
374
+ // and the child's self-reload (reexecSelf, which propagates env via childEnv) would otherwise
375
+ // re-run the secret onto each fresh generation.
376
+ if (guiToken !== undefined)
377
+ env[GUI_TOKEN_ENV] = guiToken;
350
378
  for (;;) {
351
- const exit = await spawnGuiChild(signal, { [DASHBOARD_CHILD_ENV]: String(process.pid) }, stdio, guiArgs);
379
+ const exit = await spawnGuiChild(signal, env, stdio, guiArgs);
352
380
  if (signal.aborted || exit.code !== RESTART_EXIT_CODE)
353
381
  return;
354
382
  }
@@ -409,7 +437,7 @@ async function superviseStartCommand(root, runArgs, branchArg, forMs) {
409
437
  guiController.abort();
410
438
  })
411
439
  : Promise.resolve();
412
- const gui = plan.spawnGui ? superviseDashboard(guiController.signal, plan.childStdio, plan.guiArgs) : Promise.resolve();
440
+ const gui = plan.spawnGui ? superviseDashboard(guiController.signal, plan.guiToken, plan.childStdio, plan.guiArgs) : Promise.resolve();
413
441
  const code = await superviseStart({
414
442
  spawnChild: (signal) => spawnStartChild(signal, plan.orchestratorArgs, windowEnv, plan.childStdio),
415
443
  stopping: () => stopping,