tumwater 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +14 -0
  4. package/dist/src/agent/claude/claude-stream.js +27 -31
  5. package/dist/src/agent/codex/codex-rollout.js +38 -24
  6. package/dist/src/agent/codex/codex-stream.js +71 -38
  7. package/dist/src/agent/image-input.js +18 -87
  8. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  9. package/dist/src/agent/opencode/opencode-stream.js +4 -4
  10. package/dist/src/agent/run-agent.js +11 -2
  11. package/dist/src/agent/run-settle.js +22 -13
  12. package/dist/src/agent/run-spawn.js +3 -12
  13. package/dist/src/agent/run-stream.js +2 -10
  14. package/dist/src/agent/session-ledger.js +16 -0
  15. package/dist/src/backlog/backlog-archive.js +64 -165
  16. package/dist/src/backlog/backlog-eligibility.js +50 -58
  17. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  18. package/dist/src/backlog/backlog-index-data.js +2 -2
  19. package/dist/src/backlog/backlog-md.js +38 -27
  20. package/dist/src/backlog/backlog-release.js +11 -19
  21. package/dist/src/backlog/backlog-structure.js +14 -1
  22. package/dist/src/backlog/backlog-write.js +1 -1
  23. package/dist/src/backlog/backlog.js +5 -4
  24. package/dist/src/backlog/question-answer.js +30 -14
  25. package/dist/src/baseline/main-baseline.js +30 -20
  26. package/dist/src/baseline/main-guardrails.js +54 -17
  27. package/dist/src/baseline/main-watch.js +1 -1
  28. package/dist/src/budget/budget-wiring.js +0 -5
  29. package/dist/src/budget/budget.js +2 -1
  30. package/dist/src/build/build-check-scoped.js +70 -41
  31. package/dist/src/change/change-data.js +4 -2
  32. package/dist/src/cli/cli-command-args.js +199 -98
  33. package/dist/src/cli/cli-flag-specs.js +1 -4
  34. package/dist/src/cli/cli-review-commands.js +2 -1
  35. package/dist/src/cli/cli-role-command.js +27 -50
  36. package/dist/src/cli/cli-start.js +62 -12
  37. package/dist/src/cli/follow-view.js +3 -1
  38. package/dist/src/cli/help.js +6 -7
  39. package/dist/src/cli/log-commands.js +3 -1
  40. package/dist/src/cli.js +3 -7
  41. package/dist/src/collections.js +21 -7
  42. package/dist/src/config/config-fallbacks.js +0 -4
  43. package/dist/src/config/config-live.js +179 -147
  44. package/dist/src/config/config-views.js +2 -7
  45. package/dist/src/config/config-write.js +19 -26
  46. package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
  47. package/dist/src/doctor/doctor-backlog-plans.js +8 -8
  48. package/dist/src/doctor/doctor-backlog.js +22 -36
  49. package/dist/src/doctor/doctor-check-names.js +2 -2
  50. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  51. package/dist/src/doctor/doctor-contention-checks.js +1 -1
  52. package/dist/src/doctor/doctor-holds.js +13 -11
  53. package/dist/src/doctor/doctor-model-checks.js +8 -184
  54. package/dist/src/doctor/doctor-notify.js +2 -5
  55. package/dist/src/doctor/doctor-opencode-checks.js +85 -48
  56. package/dist/src/doctor/doctor-project-checks.js +66 -17
  57. package/dist/src/doctor/doctor-role-notes.js +6 -24
  58. package/dist/src/doctor/doctor.js +6 -6
  59. package/dist/src/doctor/probe.js +1 -1
  60. package/dist/src/events/event-format.js +5 -15
  61. package/dist/src/events/event-labels.js +0 -1
  62. package/dist/src/events/event-legacy.js +1 -1
  63. package/dist/src/events/event-window.js +1 -1
  64. package/dist/src/events/events.js +15 -2
  65. package/dist/src/failure/failure-state-change.js +183 -68
  66. package/dist/src/failure/time-spend.js +1 -1
  67. package/dist/src/files/json-object.js +15 -2
  68. package/dist/src/gates/startup-gate.js +28 -28
  69. package/dist/src/git/slot-salvage.js +5 -4
  70. package/dist/src/gui/gui-admin-endpoints.js +2 -3
  71. package/dist/src/gui/gui-backlog-commands.js +1 -1
  72. package/dist/src/gui/gui-backlog-endpoints.js +1 -7
  73. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  74. package/dist/src/gui/gui-operator-commands.js +0 -12
  75. package/dist/src/gui/gui-prompt-commands.js +26 -20
  76. package/dist/src/gui/gui-report-endpoints.js +2 -1
  77. package/dist/src/gui/gui-server.js +1 -2
  78. package/dist/src/history/history.js +102 -67
  79. package/dist/src/inbox/inbox-submit.js +1 -5
  80. package/dist/src/inbox/prompt-commands.js +24 -19
  81. package/dist/src/init/init.js +68 -15
  82. package/dist/src/merge/merge-core.js +1 -1
  83. package/dist/src/merge/merge-post-review-drain.js +1 -1
  84. package/dist/src/operator/operator-commands.js +68 -47
  85. package/dist/src/operator/retire.js +2 -1
  86. package/dist/src/orchestrator/retention.js +6 -5
  87. package/dist/src/paths.js +0 -6
  88. package/dist/src/pi/pi-watchdogs.js +20 -1
  89. package/dist/src/pi-extension/context-shake.js +55 -31
  90. package/dist/src/process/process.js +23 -0
  91. package/dist/src/process/supervisor.js +17 -1
  92. package/dist/src/prompt/gate-notes.js +9 -9
  93. package/dist/src/prompt/notes-backlog.js +11 -24
  94. package/dist/src/prompt/prompt-followup.js +4 -12
  95. package/dist/src/prompt/prompt.js +4 -5
  96. package/dist/src/report/report-data.js +10 -225
  97. package/dist/src/report/report-fold.js +20 -295
  98. package/dist/src/report/report-main-red.js +45 -42
  99. package/dist/src/report/report-render.js +66 -39
  100. package/dist/src/report/report-since-data.js +218 -0
  101. package/dist/src/report/report-usage-fold.js +288 -0
  102. package/dist/src/report/report.js +2 -1
  103. package/dist/src/review/known-flakes.js +3 -3
  104. package/dist/src/review/post-merge-review-accept.js +3 -2
  105. package/dist/src/review/post-merge-review.js +9 -9
  106. package/dist/src/review/quarantine-render.js +2 -1
  107. package/dist/src/roles/role-catalog.js +18 -30
  108. package/dist/src/roles/role-guidance.js +18 -43
  109. package/dist/src/roles/role-view.js +1 -2
  110. package/dist/src/run/ci-parity.js +5 -6
  111. package/dist/src/run/qa-coverage.js +4 -4
  112. package/dist/src/run/run-detail-data.js +23 -7
  113. package/dist/src/run/run-detail-render.js +6 -0
  114. package/dist/src/run/run-detail.js +44 -22
  115. package/dist/src/run/run-dismiss.js +43 -0
  116. package/dist/src/run/run-prompt.js +100 -56
  117. package/dist/src/run/run-resume.js +32 -31
  118. package/dist/src/run/run-stage.js +4 -21
  119. package/dist/src/run/run-timing.js +18 -4
  120. package/dist/src/run/run-verdict.js +8 -73
  121. package/dist/src/run/stage-backlog-checks.js +2 -29
  122. package/dist/src/run/stage-check.js +4 -12
  123. package/dist/src/runner/runner-outcome.js +0 -5
  124. package/dist/src/runner/runner-run.js +2 -79
  125. package/dist/src/runner/runner-state-load.js +0 -10
  126. package/dist/src/runner/runner.js +1 -32
  127. package/dist/src/scheduling/claims.js +3 -10
  128. package/dist/src/status/status-data.js +5 -9
  129. package/dist/src/status/status-role-notes.js +18 -14
  130. package/dist/src/status/status-roles.js +2 -35
  131. package/dist/src/text/datetime.js +8 -1
  132. package/dist/src/text/text.js +12 -0
  133. package/dist/src/ui/alert-feeds.js +7 -212
  134. package/dist/src/ui/badges.js +2 -3
  135. package/dist/src/ui/gui/gui-client-boot.js +19 -4
  136. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  137. package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
  138. package/dist/src/ui/gui/gui-client-report.js +1 -2
  139. package/dist/src/ui/gui/gui-client-settings.js +0 -23
  140. package/dist/src/ui/gui/gui-client-team.js +26 -0
  141. package/dist/src/ui/gui/gui-icons.js +7 -1
  142. package/dist/src/ui/status-model.js +2 -2
  143. package/dist/src/ui/status-payload.js +3 -3
  144. package/dist/src/ui/status-render.js +3 -3
  145. package/dist/src/ui/team-alert-backlog.js +23 -54
  146. package/dist/src/ui/team-alert-shared.js +23 -10
  147. package/dist/src/ui/team-alert-status.js +8 -3
  148. package/dist/src/ui/team-alert-tail.js +1 -14
  149. package/dist/src/ui/team-alerts.js +6 -17
  150. package/dist/src/ui/transcript-tail.js +137 -94
  151. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  152. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  153. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  154. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  155. package/dist/src/ui/tui/tui-editor-budget.js +105 -93
  156. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  157. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  158. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  159. package/dist/src/ui/tui/tui-editor-pause.js +209 -183
  160. package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
  161. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  162. package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
  163. package/dist/src/ui/tui/tui-editor-release.js +50 -42
  164. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  165. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  166. package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
  167. package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
  168. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  169. package/dist/src/ui/tui/tui-frame.js +1 -1
  170. package/dist/src/ui/tui/tui-keys.js +1 -1
  171. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  172. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  173. package/dist/src/ui/tui/tui-pane-health.js +3 -24
  174. package/dist/src/ui/tui/tui-pane-history.js +20 -24
  175. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  176. package/dist/src/ui/tui/tui-pane-session.js +3 -2
  177. package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
  178. package/dist/src/ui/tui/tui-pane.js +201 -174
  179. package/dist/src/ui/tui/tui-panes.js +2 -3
  180. package/dist/src/ui/tui/tui.js +3 -3
  181. package/dist/src/verdict/fix-claim.js +18 -6
  182. package/dist/src/verdict/refusal.js +30 -6
  183. package/package.json +1 -1
  184. package/dist/src/doctor/image-probe.js +0 -257
  185. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  186. package/dist/src/ui/team-alert-merge.js +0 -183
@@ -247,19 +247,19 @@ const PROMPT_FLAG_SPECS = [
247
247
  { names: ["--attach"], value: true, valueName: "<path>" },
248
248
  { names: ["--grep"], value: true, valueName: "<text>" },
249
249
  ];
250
- /** `tumwater prompt` argument handling, following parseInitArgs' pattern. Like init's,
251
- * positionals are free-form prompt content — but a double-dash token must be a real flag
252
- * (`--role <id>`, `--list`, `--json`, `--grep <text>`, `--cancel <n>`, `--edit <n>`,
253
- * `--top <n>`, `--file <path>`, `--at <duration>`), or
254
- * it would be baked into the queued prompt (the same class of bug parseInitArgs fixed: today
255
- * `tumwater prompt --foo text` enqueues "--foo text"). Single-dash positionals remain prompt
256
- * content. `--list`, `--cancel`, `--edit` and `--top` are mutually exclusive and may
257
- * not combine with positional text; `--role` is accepted in every mode and is never
258
- * prompt content. `--at <duration>` (PLANS.md "tumwater prompt --at <duration>") only queues
259
- * a prompt: it defers delivery — parsed here with parseDurationFlag, riding the enqueue as
260
- * the queue file's not-before marker — so the read-only and destructive modes refuse it
261
- * rather than silently ignore it. */
262
- export function parsePromptArgs(args) {
250
+ /** The six `--list`/`--cancel`/`--edit`/`--top` mode pairs that may not combine, in today's
251
+ * exact order. */
252
+ const PROMPT_MODE_EXCLUSIONS = [
253
+ ["--list", "--cancel"],
254
+ ["--list", "--edit"],
255
+ ["--cancel", "--edit"],
256
+ ["--list", "--top"],
257
+ ["--cancel", "--top"],
258
+ ["--edit", "--top"],
259
+ ];
260
+ /** The flag-vocabulary gate and the once-check: the unknown-double-dash gate, the ten index
261
+ * collections and rejectDuplicateFlags's once-check all ride here, one scan, one home. */
262
+ function scanPromptFlags(args) {
263
263
  // The unknown-double-dash gate reads PROMPT_FLAG_SPECS, so the list its error names admits
264
264
  // exactly what the duplicate-flag check below accepts — one vocabulary, one message renderer.
265
265
  rejectUnknownDoubleDash("prompt", args, PROMPT_FLAG_SPECS);
@@ -276,21 +276,30 @@ export function parsePromptArgs(args) {
276
276
  // --attach is repeatable: every occurrence claims itself and its value token, like --role's
277
277
  // single pair, so the pairs ride out of the prompt text in every mode.
278
278
  const attachFlags = args.flatMap((a, i) => (a === "--attach" ? [i] : []));
279
+ const roleFlag = args.indexOf("--role");
279
280
  const known = PROMPT_FLAG_SPECS.flatMap((s) => s.names).filter((f) => f !== "--attach"); // repeatable: the once-check skips it
280
281
  rejectDuplicateFlags(args, known);
281
- if (listFlag >= 0 && cancelFlag >= 0)
282
- fail("--list and --cancel are mutually exclusive");
283
- if (listFlag >= 0 && editFlag >= 0)
284
- fail("--list and --edit are mutually exclusive");
285
- if (cancelFlag >= 0 && editFlag >= 0)
286
- fail("--cancel and --edit are mutually exclusive");
287
- if (listFlag >= 0 && topFlag >= 0)
288
- fail("--list and --top are mutually exclusive");
289
- if (cancelFlag >= 0 && topFlag >= 0)
290
- fail("--cancel and --top are mutually exclusive");
291
- if (editFlag >= 0 && topFlag >= 0)
292
- fail("--edit and --top are mutually exclusive");
293
- const roleFlag = args.indexOf("--role");
282
+ return { jsonFlags, listFlag, cancelFlag, editFlag, topFlag, fileFlag, atFlag, grepFlag, attachFlags, roleFlag };
283
+ }
284
+ /** The six mode mutual exclusions: the mutually-exclusive `--list`/`--cancel`/`--edit`/
285
+ * `--top` pairs, in the table's order, so the first failing pair reports as it did before. */
286
+ function assertPromptModesDisjoint(scan) {
287
+ const index = {
288
+ "--list": scan.listFlag,
289
+ "--cancel": scan.cancelFlag,
290
+ "--edit": scan.editFlag,
291
+ "--top": scan.topFlag,
292
+ };
293
+ for (const [a, b] of PROMPT_MODE_EXCLUSIONS) {
294
+ // All four keys are always present in the object; ?? -1 just satisfies the strict
295
+ // indexed-access typing without changing the (never-undefined) compare.
296
+ if ((index[a] ?? -1) >= 0 && (index[b] ?? -1) >= 0)
297
+ fail(`${a} and ${b} are mutually exclusive`);
298
+ }
299
+ }
300
+ /** The `--role <id>` value pair: its raw value, its missing-value refusal and the pair it
301
+ * claims, verbatim. */
302
+ function parsePromptRole(args, roleFlag) {
294
303
  const roleRaw = roleFlag >= 0 ? args[roleFlag + 1] : undefined;
295
304
  if (roleFlag >= 0 && (!roleRaw || roleRaw.startsWith("--")))
296
305
  fail(ROLE_VALUE_ERROR);
@@ -298,17 +307,30 @@ export function parsePromptArgs(args) {
298
307
  // The flag and its value are never prompt content: --role is a scope, not text. Only claimed
299
308
  // when actually present — negative indexes would over-claim real tokens.
300
309
  const roleClaim = roleFlag >= 0 ? [roleFlag, roleFlag + 1] : [];
310
+ return { role, roleClaim };
311
+ }
312
+ /** The `--json` list-only refusal: in enqueue or cancel mode it must never silently ride
313
+ * along as prompt text (or beside a state change). */
314
+ function assertPromptJsonListOnly(scan) {
301
315
  // --json is list-only: in enqueue or cancel mode it must never silently ride along as
302
316
  // prompt text (or beside a state change), so it is refused before either branch runs.
303
- if (jsonFlags.length > 0 && listFlag < 0)
317
+ if (scan.jsonFlags.length > 0 && scan.listFlag < 0)
304
318
  fail("--json only applies to --list");
305
- if (grepFlag >= 0 && listFlag < 0)
319
+ }
320
+ /** The `--grep <text>` value pair and its list-only refusal, verbatim. */
321
+ function parsePromptGrep(args, scan) {
322
+ if (scan.grepFlag >= 0 && scan.listFlag < 0)
306
323
  fail("--grep only applies to --list");
307
- const grepRaw = grepFlag >= 0 ? args[grepFlag + 1] : undefined;
308
- if (grepFlag >= 0 && (!grepRaw || grepRaw.startsWith("--"))) {
324
+ const grepRaw = scan.grepFlag >= 0 ? args[scan.grepFlag + 1] : undefined;
325
+ if (scan.grepFlag >= 0 && (!grepRaw || grepRaw.startsWith("--"))) {
309
326
  fail("--grep needs text (usage: tumwater prompt --list --grep <text>)");
310
327
  }
311
- const grepClaim = grepFlag >= 0 ? [grepFlag, grepFlag + 1] : [];
328
+ const grepClaim = scan.grepFlag >= 0 ? [scan.grepFlag, scan.grepFlag + 1] : [];
329
+ return { grepRaw, grepClaim };
330
+ }
331
+ /** The queue-only-mode refusals: --file and --at have nothing to mean except in the enqueue
332
+ * mode, and --attach is refused by --list and --cancel (--edit accepts it). */
333
+ function assertPromptQueueOnlyFlags(scan) {
312
334
  // The queue-only flags --file and --at have nothing to mean in the read-only and destructive
313
335
  // modes (--list and --cancel): --file is the file-shaped twin of free-form text (--edit
314
336
  // accepts it as its replacement text, so the editFlag test below exempts that one mode), and
@@ -318,16 +340,16 @@ export function parsePromptArgs(args) {
318
340
  // predicate and the `<flag> only queues a prompt` wording, so a new mode or queue-only flag
319
341
  // is added here.
320
342
  const refuseQueueOnlyFlag = (flag, present) => {
321
- if (present && (listFlag >= 0 || cancelFlag >= 0 || editFlag >= 0 || topFlag >= 0))
343
+ if (present && (scan.listFlag >= 0 || scan.cancelFlag >= 0 || scan.editFlag >= 0 || scan.topFlag >= 0))
322
344
  fail(`${flag} only queues a prompt`);
323
345
  };
324
- refuseQueueOnlyFlag("--file", fileFlag >= 0 && editFlag < 0);
325
- refuseQueueOnlyFlag("--at", atFlag >= 0);
326
- refuseQueueOnlyFlag("--attach", attachFlags.length > 0 && editFlag < 0);
327
- // An edit keeps the target prompt's existing deferral exactly as it was (the marker is
328
- // plumbing the edit carries over), so a --at alongside it has nothing to mean.
329
- // The pair is claimed like --role's: the flag and its value are never prompt content.
330
- const atClaim = atFlag >= 0 ? [atFlag, atFlag + 1] : [];
346
+ refuseQueueOnlyFlag("--file", scan.fileFlag >= 0 && scan.editFlag < 0);
347
+ refuseQueueOnlyFlag("--at", scan.atFlag >= 0);
348
+ refuseQueueOnlyFlag("--attach", scan.attachFlags.length > 0 && scan.editFlag < 0);
349
+ }
350
+ /** The `--attach <path>` value pair(s): every occurrence claims itself and its value token,
351
+ * with the missing-value refusal, verbatim. */
352
+ function parsePromptAttachments(args, attachFlags) {
331
353
  // Each --attach pair claims the flag and its value; a trailing flag with no value names the
332
354
  // flag here, like --cancel's missing-value wording, before any queue write.
333
355
  const attachPaths = attachFlags.map((i) => args[i + 1]);
@@ -336,6 +358,14 @@ export function parsePromptArgs(args) {
336
358
  if (args[i + 1] === undefined)
337
359
  fail(`--attach needs a path (e.g. \`--attach shot.png\`)`);
338
360
  }
361
+ return { attachPaths, attachClaims };
362
+ }
363
+ /** The `--at <duration>` parse and its 90-day cap, verbatim. */
364
+ function parsePromptDeferral(args, atFlag) {
365
+ // An edit keeps the target prompt's existing deferral exactly as it was (the marker is
366
+ // plumbing the edit carries over), so a --at alongside it has nothing to mean.
367
+ // The pair is claimed like --role's: the flag and its value are never prompt content.
368
+ const atClaim = atFlag >= 0 ? [atFlag, atFlag + 1] : [];
339
369
  let atDelayMs = null;
340
370
  if (atFlag >= 0) {
341
371
  atDelayMs = parseDurationFlag("--at", args[atFlag + 1]);
@@ -344,75 +374,146 @@ export function parsePromptArgs(args) {
344
374
  // (about 106751991d from now) made notBeforeMarker's toISOString throw a raw RangeError.
345
375
  failOverDurationCap("--at", atDelayMs, PAUSE_FOR_MAX_MS);
346
376
  }
347
- if (listFlag >= 0) {
348
- failStrayArg(args, "with --list there is no prompt text", listFlag, ...jsonFlags, ...roleClaim, ...grepClaim);
349
- return { mode: "list", role, json: jsonFlags.length > 0, grep: grepRaw ?? null };
350
- }
351
- if (cancelFlag >= 0) {
352
- const n = parsePositionFlag("--cancel", args[cancelFlag + 1], "--cancel 2");
353
- // The position is the only token --cancel may carry; anything else alongside it would be
354
- // prompt text, and this mode has none.
355
- failStrayArg(args, "with --cancel there is no prompt text", cancelFlag, cancelFlag + 1, ...roleClaim);
356
- return { mode: "cancel", role, position: n };
357
- }
358
- if (editFlag >= 0) {
359
- const n = parsePositionFlag("--edit", args[editFlag + 1], '--edit 2 "new text"');
360
- // The --edit pair, the --role pair, and any --attach pairs are the only flags this mode
361
- // claims. Tokens preceding --edit (other than a claimed flag) would be silently dropped —
362
- // a mistyped command would edit prompt N with only the trailing fragment — so they are
363
- // refused here by the same stray-argument wording the sibling modes use; tokens after the
364
- // position are the replacement text, minus any claimed attach pair (none: edit in $EDITOR).
365
- const fileClaim = fileFlag >= 0 ? [fileFlag, fileFlag + 1] : [];
366
- for (let i = 0; i < editFlag; i++) {
367
- if (roleClaim.includes(i) || attachClaims.includes(i) || fileClaim.includes(i))
368
- continue;
369
- fail(`unexpected argument ${JSON.stringify(args[i])} — with --edit the replacement text comes after the position`);
370
- }
371
- if (fileFlag >= 0) {
372
- // The replacement comes from the file (`-` reads stdin), kept line for line; editPromptAtFile
373
- // trims it. Typed text beside it would be ambiguous, so it is refused.
374
- const file = promptFileValue(args[fileFlag + 1]);
375
- failStrayArg(args, "with --file the replacement text comes from the file", editFlag, editFlag + 1, ...fileClaim, ...roleClaim, ...attachClaims);
376
- return { mode: "edit", role, position: n, text: readPromptFileArg(file), attachPaths };
377
- }
378
- const textTokens = [];
379
- for (let i = editFlag + 2; i < args.length; i++) {
380
- if (roleClaim.includes(i) || attachClaims.includes(i))
381
- continue;
382
- textTokens.push(args[i]);
383
- }
384
- const text = textTokens.join(" ").trim();
385
- // No text and no --file: the caller opens the prompt's current body in the editor.
386
- return { mode: "edit", role, position: n, text: text === "" ? null : text, attachPaths };
377
+ return { atDelayMs, atClaim };
378
+ }
379
+ /** `tumwater prompt`'s flag scan and the checks it owns — everything shared by two or more
380
+ * modes, so a new mode or queue-only flag is added here: the unknown-double-dash gate, the
381
+ * index collection, the once-check, the six mutual exclusions, the --role pair, the
382
+ * --json/--grep list-only refusals, the queue-only-flag refusals, the --attach pairs and the
383
+ * --at parse and cap. Each job lives in one helper — scanPromptFlags, assertPromptModesDisjoint,
384
+ * parsePromptRole, assertPromptJsonListOnly, parsePromptGrep, assertPromptQueueOnlyFlags,
385
+ * parsePromptAttachments and parsePromptDeferral — so a check is found by name. Returns a
386
+ * struct the mode branches read. */
387
+ function parsePromptFlags(args) {
388
+ const scan = scanPromptFlags(args);
389
+ assertPromptModesDisjoint(scan);
390
+ const { role, roleClaim } = parsePromptRole(args, scan.roleFlag);
391
+ assertPromptJsonListOnly(scan);
392
+ const { grepRaw, grepClaim } = parsePromptGrep(args, scan);
393
+ assertPromptQueueOnlyFlags(scan);
394
+ const { attachPaths, attachClaims } = parsePromptAttachments(args, scan.attachFlags);
395
+ const { atDelayMs, atClaim } = parsePromptDeferral(args, scan.atFlag);
396
+ return {
397
+ role,
398
+ jsonFlags: scan.jsonFlags,
399
+ listFlag: scan.listFlag,
400
+ cancelFlag: scan.cancelFlag,
401
+ editFlag: scan.editFlag,
402
+ topFlag: scan.topFlag,
403
+ fileFlag: scan.fileFlag,
404
+ grepRaw,
405
+ grepClaim,
406
+ roleClaim,
407
+ attachPaths,
408
+ attachClaims,
409
+ atDelayMs,
410
+ atClaim,
411
+ };
412
+ }
413
+ /** `--list`: the queue listing, optionally machine-readable and filtered by --grep. */
414
+ function listPromptArgs(args, f) {
415
+ failStrayArg(args, "with --list there is no prompt text", f.listFlag, ...f.jsonFlags, ...f.roleClaim, ...f.grepClaim);
416
+ return { mode: "list", role: f.role, json: f.jsonFlags.length > 0, grep: f.grepRaw ?? null };
417
+ }
418
+ /** `--cancel <n>`: remove one entry by its 1-based queue position. */
419
+ function cancelPromptArgs(args, f) {
420
+ const n = parsePositionFlag("--cancel", args[f.cancelFlag + 1], "--cancel 2");
421
+ // The position is the only token --cancel may carry; anything else alongside it would be
422
+ // prompt text, and this mode has none.
423
+ failStrayArg(args, "with --cancel there is no prompt text", f.cancelFlag, f.cancelFlag + 1, ...f.roleClaim);
424
+ return { mode: "cancel", role: f.role, position: n };
425
+ }
426
+ /** `--edit <n>`: rewrite one entry's text in place — the replacement is the --file contents,
427
+ * the tokens after the position, or (with neither) an editor open in the caller. */
428
+ function editPromptArgs(args, f) {
429
+ const n = parsePositionFlag("--edit", args[f.editFlag + 1], '--edit 2 "new text"');
430
+ // The --edit pair, the --role pair, and any --attach pairs are the only flags this mode
431
+ // claims. Tokens preceding --edit (other than a claimed flag) would be silently dropped —
432
+ // a mistyped command would edit prompt N with only the trailing fragment — so they are
433
+ // refused here by the same stray-argument wording the sibling modes use; tokens after the
434
+ // position are the replacement text, minus any claimed attach pair (none: edit in $EDITOR).
435
+ const fileClaim = f.fileFlag >= 0 ? [f.fileFlag, f.fileFlag + 1] : [];
436
+ for (let i = 0; i < f.editFlag; i++) {
437
+ if (f.roleClaim.includes(i) || f.attachClaims.includes(i) || fileClaim.includes(i))
438
+ continue;
439
+ fail(`unexpected argument ${JSON.stringify(args[i])} — with --edit the replacement text comes after the position`);
387
440
  }
388
- if (topFlag >= 0) {
389
- const n = parsePositionFlag("--top", args[topFlag + 1], "--top 2");
390
- // The position is the only token --top may carry; anything else alongside it would be
391
- // prompt text, and this mode has none.
392
- failStrayArg(args, "with --top there is no prompt text", topFlag, topFlag + 1, ...roleClaim);
393
- return { mode: "top", role, position: n };
441
+ if (f.fileFlag >= 0) {
442
+ // The replacement comes from the file (`-` reads stdin), kept line for line; editPromptAtFile
443
+ // trims it. Typed text beside it would be ambiguous, so it is refused.
444
+ const file = promptFileValue(args[f.fileFlag + 1]);
445
+ failStrayArg(args, "with --file the replacement text comes from the file", f.editFlag, f.editFlag + 1, ...fileClaim, ...f.roleClaim, ...f.attachClaims);
446
+ return { mode: "edit", role: f.role, position: n, text: readPromptFileArg(file), attachPaths: f.attachPaths };
394
447
  }
395
- if (fileFlag >= 0) {
396
- // The file is the prompt: read it whole (a path of `-` reads stdin, so a pipe or heredoc
397
- // works), following parseInitArgs' --file branch — the same claim/stray/read/empty shape,
398
- // so the two commands speak one idiom. The stdin read is memoized at module level: cli.ts
399
- // pre-parses prompt's args and cmdPrompt re-parses them, and a second readFileSync(0) on a
400
- // drained pipe would see an empty prompt (readFileSync(0) fails with EAGAIN on a TTY's
401
- // stdin; errorMessage names it, and the operator passes a real pipe instead).
402
- const file = promptFileValue(args[fileFlag + 1]);
403
- failStrayArg(args, "with --file the prompt comes from the file", fileFlag, fileFlag + 1, ...roleClaim, ...atClaim, ...attachClaims);
404
- const contents = readPromptFileArg(file);
405
- return { mode: "enqueue", role, text: contents, atDelayMs, attachPaths: attachPaths };
448
+ const textTokens = [];
449
+ for (let i = f.editFlag + 2; i < args.length; i++) {
450
+ if (f.roleClaim.includes(i) || f.attachClaims.includes(i))
451
+ continue;
452
+ textTokens.push(args[i]);
406
453
  }
454
+ const text = textTokens.join(" ").trim();
455
+ // No text and no --file: the caller opens the prompt's current body in the editor.
456
+ return { mode: "edit", role: f.role, position: n, text: text === "" ? null : text, attachPaths: f.attachPaths };
457
+ }
458
+ /** `--top <n>`: move one entry to the head of its role's queue. */
459
+ function topPromptArgs(args, f) {
460
+ const n = parsePositionFlag("--top", args[f.topFlag + 1], "--top 2");
461
+ // The position is the only token --top may carry; anything else alongside it would be
462
+ // prompt text, and this mode has none.
463
+ failStrayArg(args, "with --top there is no prompt text", f.topFlag, f.topFlag + 1, ...f.roleClaim);
464
+ return { mode: "top", role: f.role, position: n };
465
+ }
466
+ /** `--file <path>`: enqueue the file's (or stdin's) whole contents as the prompt. */
467
+ function filePromptArgs(args, f) {
468
+ // The file is the prompt: read it whole (a path of `-` reads stdin, so a pipe or heredoc
469
+ // works), following parseInitArgs' --file branch — the same claim/stray/read/empty shape,
470
+ // so the two commands speak one idiom. The stdin read is memoized at module level: cli.ts
471
+ // pre-parses prompt's args and cmdPrompt re-parses them, and a second readFileSync(0) on a
472
+ // drained pipe would see an empty prompt (readFileSync(0) fails with EAGAIN on a TTY's
473
+ // stdin; errorMessage names it, and the operator passes a real pipe instead).
474
+ const file = promptFileValue(args[f.fileFlag + 1]);
475
+ failStrayArg(args, "with --file the prompt comes from the file", f.fileFlag, f.fileFlag + 1, ...f.roleClaim, ...f.atClaim, ...f.attachClaims);
476
+ const contents = readPromptFileArg(file);
477
+ return { mode: "enqueue", role: f.role, text: contents, atDelayMs: f.atDelayMs, attachPaths: f.attachPaths };
478
+ }
479
+ /** The default mode: free-form text (minus the claimed --role/--at/--attach tokens) is joined
480
+ * and enqueued. */
481
+ function enqueuePromptArgs(args, f) {
407
482
  // Everything except the --role pair and the --at pair is prompt text; the join keeps
408
483
  // multi-word requests as one string exactly like the pre-1/2 behavior.
409
484
  const text = args
410
- .filter((_, i) => !roleClaim.includes(i) && !atClaim.includes(i) && !attachClaims.includes(i))
485
+ .filter((_, i) => !f.roleClaim.includes(i) && !f.atClaim.includes(i) && !f.attachClaims.includes(i))
411
486
  .join(" ")
412
487
  .trim();
413
488
  // Name the fix, like the sibling operator commands' usage errors (bug/plan carry their
414
489
  // BUG_USAGE/PLAN_USAGE lines): "prompt text required" alone never said what to type.
415
490
  if (!text)
416
491
  fail('prompt text required — usage: tumwater prompt "<text>" (add --role <id> to aim it at one role)');
417
- return { mode: "enqueue", role, text, atDelayMs, attachPaths: attachPaths };
492
+ return { mode: "enqueue", role: f.role, text, atDelayMs: f.atDelayMs, attachPaths: f.attachPaths };
493
+ }
494
+ /** `tumwater prompt` argument handling, following parseInitArgs' pattern. Like init's,
495
+ * positionals are free-form prompt content — but a double-dash token must be a real flag
496
+ * (`--role <id>`, `--list`, `--json`, `--grep <text>`, `--cancel <n>`, `--edit <n>`,
497
+ * `--top <n>`, `--file <path>`, `--at <duration>`), or
498
+ * it would be baked into the queued prompt (the same class of bug parseInitArgs fixed: today
499
+ * `tumwater prompt --foo text` enqueues "--foo text"). Single-dash positionals remain prompt
500
+ * content. `--list`, `--cancel`, `--edit` and `--top` are mutually exclusive and may
501
+ * not combine with positional text; `--role` is accepted in every mode and is never
502
+ * prompt content. `--at <duration>` (PLANS.md "tumwater prompt --at <duration>") only queues
503
+ * a prompt: it defers delivery — parsed here with parseDurationFlag, riding the enqueue as
504
+ * the queue file's not-before marker — so the read-only and destructive modes refuse it
505
+ * rather than silently ignore it. */
506
+ export function parsePromptArgs(args) {
507
+ const f = parsePromptFlags(args);
508
+ if (f.listFlag >= 0)
509
+ return listPromptArgs(args, f);
510
+ if (f.cancelFlag >= 0)
511
+ return cancelPromptArgs(args, f);
512
+ if (f.editFlag >= 0)
513
+ return editPromptArgs(args, f);
514
+ if (f.topFlag >= 0)
515
+ return topPromptArgs(args, f);
516
+ if (f.fileFlag >= 0)
517
+ return filePromptArgs(args, f);
518
+ return enqueuePromptArgs(args, f);
418
519
  }
@@ -257,11 +257,8 @@ export function hasFollowFlag(args) {
257
257
  /** The `--ready` flag spec, accepted by `backlog`: keep only the entries no hold keeps from
258
258
  * being picked. Value-less, like STAT_FLAG. */
259
259
  export const READY_FLAG = { names: ["--ready"] };
260
- /** Force re-probing of every claimed image-input model in `tumwater doctor` (part 2/5),
261
- * bypassing the 7-day cache: `--reprobe` (a value-less flag like JSON_FLAG). */
262
- export const REPROBE_FLAG = { names: ["--reprobe"] };
263
260
  /** The `--problems` flag spec, accepted by `doctor`: print only the warn and fail rows. Value-less,
264
- * like REPROBE_FLAG. */
261
+ * like KILL_ORPHANS_FLAG. */
265
262
  export const PROBLEMS_FLAG = { names: ["--problems"] };
266
263
  /** The `--kill-orphans` flag spec, accepted by `doctor`: first kill the orphaned processes the
267
264
  * orphans check lists, each with its descendants. Value-less, like PROBLEMS_FLAG. */
@@ -4,7 +4,8 @@
4
4
  * `readPostReviews` reads a missing, torn or hand-edited file as the well-formed subset (or `[]`),
5
5
  * so the command prints the empty line in any directory. Read-only except the one destructive
6
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
7
+ * (the action the doctor's at-risk post-review row's advice names); that mode adds no new
8
+ * ready-repo requirement to
8
9
  * the read path and gates the write with `requireReadyRepo`. Unlike `cmdQueue`'s `--cancel`, the
9
10
  * read case still writes no file. */
10
11
  import { JSON_FLAG, ROLE_FLAG, rejectUnknownArgs, statusFlagSpec } from "./cli-flag-specs.js";
@@ -17,7 +17,7 @@ import { renderRoleMarkdown } from "../roles/role-render.js";
17
17
  import { DIRECTOR_ROLE, unknownRoleMessage } from "../roles/roles.js";
18
18
  import { saveRoleNote } from "../agent/role-notes-write.js";
19
19
  import { roleNotesPath } from "../paths.js";
20
- import { errorMessage } from "../text/text.js";
20
+ import { errorMessage, isExponentSpelling } from "../text/text.js";
21
21
  import { readPromptFileArg } from "./cli-command-args.js";
22
22
  import { canonicalRoleId } from "../roles/legacy-role-ids.js";
23
23
  import { INSTANCE_ROLES, baseRoleOf } from "../roles/runner-ids.js";
@@ -44,12 +44,6 @@ 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
- }
53
47
  /** `role`'s min-run-interval write flags: clear it, or set it to a second count (0 is
54
48
  * back-to-back). */
55
49
  const CLEAR_MIN_RUN_INTERVAL_SPEC = { names: ["--clear-min-run-interval"] };
@@ -327,7 +321,7 @@ export async function cmdRole(root, args) {
327
321
  // already a plain decimal, so the exponent form is exactly the silent-write set; hex,
328
322
  // negative and non-numeric spellings fall through to the write path, whose
329
323
  // validateConfig message names the key.
330
- if (isExponentSpelling(cap))
324
+ if (isExponentSpelling(cap.trim()))
331
325
  fail(`${cfgKey} must be a plain dollar amount like 25, 12.50, or .5 — no +/-, exponent, or hex notation`);
332
326
  writeRoleConfigKey(root, base, clearCap, cap, cfgKey, "daily cap", (v) => plainDecimalToJson(v) ?? v, (v) => v);
333
327
  return;
@@ -354,7 +348,7 @@ export async function cmdRole(root, args) {
354
348
  // plain decimal, so the exponent form is exactly the silent-write set; hex, negative and
355
349
  // non-numeric spellings fall through to the write path, whose validateConfig message
356
350
  // names the key.
357
- if (isExponentSpelling(secs))
351
+ if (isExponentSpelling(secs.trim()))
358
352
  fail(`${cfgKey} must be a plain second count like 300, 0.5, or .5 — no +/-, exponent, or hex notation`);
359
353
  writeRoleConfigKey(root, base, clearInterval, secs, cfgKey, "min run interval", (v) => plainDecimalToJson(v) ?? v, (v) => `${v}s`);
360
354
  return;
@@ -384,7 +378,7 @@ export async function cmdRole(root, args) {
384
378
  // or out ("9e0" → 9 too) — is refused here, while fractional, hex, negative and plain
385
379
  // out-of-range integer spellings fall through to the write path, whose validateConfig
386
380
  // message names the key.
387
- if (isExponentSpelling(n))
381
+ if (isExponentSpelling(n.trim()))
388
382
  fail(`${cfgKey} must be a plain integer from 1 to 8 — no exponent notation`);
389
383
  // An absent count already means 1 (instanceCount's default), so asking for 1 is a no-op —
390
384
  // but only for a role that may split; any other role falls through to validateConfig's refusal.
@@ -396,47 +390,30 @@ export async function cmdRole(root, args) {
396
390
  writeRoleConfigKey(root, base, clearInstances, n, `roles.${base}.instances`, "instances", (v) => v, (v) => v);
397
391
  return;
398
392
  }
399
- // The model-triple writes: the terminal twins of `config set roles.<base>.provider|model|thinking
400
- // <value>` and `config unset` — the three fields configForRole resolves through seamOverrides.
401
- // `tumwater role <id>` renders them as its Model: line. Each lands in roles.<base>.<field>.
402
- // Through setConfigKey/unsetConfigKey, validateConfig's checkModelTripleField ("tier-name"
403
- // shape) owns validation (model accepts a tier name; thinking must be a THINKING_LEVELS level)
404
- // and each write is atomic. No director refusal: accepted exactly as --min-run-interval is.
405
- if (providerWrite) {
406
- rejectSetClearAndJson(rest, "--provider", "--clear-provider", "a provider write");
407
- requireKnownRole(root, id);
408
- const base = baseRoleOf(id);
409
- const provider = flagValue(rest, "--provider") ?? "";
410
- writeRoleConfigKey(root, base, clearProvider, provider, `roles.${base}.provider`, "provider", (v) => JSON.stringify(v), (v) => v);
411
- return;
412
- }
413
- if (modelWrite) {
414
- rejectSetClearAndJson(rest, "--model", "--clear-model", "a model write");
415
- requireKnownRole(root, id);
416
- const base = baseRoleOf(id);
417
- const model = flagValue(rest, "--model") ?? "";
418
- writeRoleConfigKey(root, base, clearModel, model, `roles.${base}.model`, "model", (v) => JSON.stringify(v), (v) => v);
419
- return;
420
- }
421
- if (thinkingWrite) {
422
- rejectSetClearAndJson(rest, "--thinking", "--clear-thinking", "a thinking write");
423
- requireKnownRole(root, id);
424
- const base = baseRoleOf(id);
425
- const level = flagValue(rest, "--thinking") ?? "";
426
- writeRoleConfigKey(root, base, clearThinking, level, `roles.${base}.thinking`, "thinking", (v) => JSON.stringify(v), (v) => v);
427
- return;
428
- }
429
- // The backend write: the twin of the provider/model/thinking writes, landing the role's
430
- // backend (`pi`, `claude`, `opencode` or `codex`) in roles.<base>.backend through the same
431
- // setConfigKey/unsetConfigKey core, so validateConfig's checkEntryBackend owns the id
432
- // (rejecting an unknown one, and a `model` that cannot pair with the named backend) and
433
- // the write stays atomic. An instance id writes its base role's key, like the others.
434
- if (backendWrite) {
435
- rejectSetClearAndJson(rest, "--backend", "--clear-backend", "a backend write");
393
+ // The model-writing branches — provider, model, thinking and backend — are one shape: each
394
+ // lands the role's field in roles.<base>.<field> (the terminal twin of `config set
395
+ // roles.<base>.<field> <value>` and `config unset`) through the same setConfigKey/unsetConfigKey
396
+ // core, so validateConfig owns the id — checkModelTripleField ("tier-name" shape: model
397
+ // accepts a tier name, thinking a THINKING_LEVELS level) for the provider/model/thinking
398
+ // fields that configForRole resolves through seamOverrides, checkEntryBackend for backend
399
+ // (`pi`, `claude`, `opencode` or `codex`, refusing an unknown one and a model that cannot
400
+ // pair with it) — and each write is atomic. No director refusal: each is accepted exactly as
401
+ // --min-run-interval is, and an instance id writes its base role's key. Every branch calls
402
+ // writeRoleConfigKey with the same JSON-encode/identity shaping, differing only in the flag
403
+ // spellings, the key field and the noun the confirmation line names — one table, one body.
404
+ for (const { flag, clearFlag, write, clear, field, noun } of [
405
+ { flag: "--provider", clearFlag: "--clear-provider", write: providerWrite, clear: clearProvider, field: "provider", noun: "provider" },
406
+ { flag: "--model", clearFlag: "--clear-model", write: modelWrite, clear: clearModel, field: "model", noun: "model" },
407
+ { flag: "--thinking", clearFlag: "--clear-thinking", write: thinkingWrite, clear: clearThinking, field: "thinking", noun: "thinking" },
408
+ { flag: "--backend", clearFlag: "--clear-backend", write: backendWrite, clear: clearBackend, field: "backend", noun: "backend" },
409
+ ]) {
410
+ if (!write)
411
+ continue;
412
+ rejectSetClearAndJson(rest, flag, clearFlag, `a ${noun} write`);
436
413
  requireKnownRole(root, id);
437
414
  const base = baseRoleOf(id);
438
- const backend = flagValue(rest, "--backend") ?? "";
439
- writeRoleConfigKey(root, base, clearBackend, backend, `roles.${base}.backend`, "backend", (v) => JSON.stringify(v), (v) => v);
415
+ const value = flagValue(rest, flag) ?? "";
416
+ writeRoleConfigKey(root, base, clear, value, `roles.${base}.${field}`, noun, (v) => JSON.stringify(v), (v) => v);
440
417
  return;
441
418
  }
442
419
  // The enable/disable write: the scoped write for the enabled state `tumwater role <id>`
@@ -466,7 +443,7 @@ export async function cmdRole(root, args) {
466
443
  say(`${base}: ${state}`);
467
444
  return;
468
445
  }
469
- // The review-after-merge write: the one command the post-merge churn alert's remedy
446
+ // The review-after-merge write: the one command the post-merge churn row's remedy
470
447
  // needs — add the role to, or remove it from, review.afterMerge for the base id, the
471
448
  // membership test reviewedAfterMerge shares. The whole-list value lands through the same
472
449
  // setConfigKey core `config set review.afterMerge <json>` uses, so validateConfig owns the