tumwater 0.1.2 → 0.1.3

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 (95) hide show
  1. package/README.md +26 -95
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/backlog/backlog-commit.js +31 -0
  4. package/dist/src/backlog/backlog-eligibility.js +41 -6
  5. package/dist/src/backlog/backlog-md.js +19 -1
  6. package/dist/src/backlog/backlog-write.js +152 -11
  7. package/dist/src/baseline/main-red.js +10 -4
  8. package/dist/src/brief-edit.js +42 -0
  9. package/dist/src/brief.js +33 -2
  10. package/dist/src/change/change-data.js +12 -3
  11. package/dist/src/cli/brief-command.js +56 -0
  12. package/dist/src/cli/cli-command-args.js +3 -4
  13. package/dist/src/cli/cli-editor.js +52 -0
  14. package/dist/src/cli/cli-flag-specs.js +23 -10
  15. package/dist/src/cli/cli-output.js +9 -0
  16. package/dist/src/cli/cli-query-commands.js +32 -6
  17. package/dist/src/cli/cli-run.js +105 -12
  18. package/dist/src/cli/completion.js +149 -0
  19. package/dist/src/cli/help.js +71 -16
  20. package/dist/src/cli/question-commands.js +18 -2
  21. package/dist/src/cli.js +49 -10
  22. package/dist/src/config/config-views.js +12 -12
  23. package/dist/src/config/config-write.js +1 -1
  24. package/dist/src/doctor/doctor-backlog.js +75 -4
  25. package/dist/src/doctor/doctor-checks.js +31 -4
  26. package/dist/src/doctor/doctor-holds.js +83 -4
  27. package/dist/src/doctor/doctor-notify.js +28 -0
  28. package/dist/src/doctor/doctor.js +9 -2
  29. package/dist/src/events/event-format.js +12 -1
  30. package/dist/src/events/notify.js +75 -26
  31. package/dist/src/fleet/fleet-state.js +25 -1
  32. package/dist/src/fleet/orchestrator-info.js +2 -0
  33. package/dist/src/git/commit-message.js +6 -0
  34. package/dist/src/git/git.js +11 -0
  35. package/dist/src/gui/gui-endpoint-commands.js +52 -5
  36. package/dist/src/gui/gui-endpoints.js +12 -3
  37. package/dist/src/gui/gui-server.js +5 -2
  38. package/dist/src/history/history-data.js +21 -0
  39. package/dist/src/history/history.js +24 -4
  40. package/dist/src/inbox/inbox-attachments.js +9 -4
  41. package/dist/src/inbox/prompt-commands.js +61 -12
  42. package/dist/src/landing/landing-git.js +1 -2
  43. package/dist/src/landing/landing-queue-render.js +28 -3
  44. package/dist/src/landing/landing-slot.js +10 -0
  45. package/dist/src/loop/loop-pi.js +2 -2
  46. package/dist/src/loop/loop-state.js +9 -0
  47. package/dist/src/loop/loop-tick.js +9 -3
  48. package/dist/src/loop/loop.js +5 -1
  49. package/dist/src/operator/operator-commands.js +43 -4
  50. package/dist/src/operator/operator-intent.js +18 -1
  51. package/dist/src/operator/retire.js +17 -1
  52. package/dist/src/orchestrator/orchestrator-scheduling.js +60 -6
  53. package/dist/src/orchestrator/orchestrator.js +10 -5
  54. package/dist/src/paths.js +5 -0
  55. package/dist/src/pi/pi-watchdogs.js +44 -41
  56. package/dist/src/process/supervisor.js +15 -4
  57. package/dist/src/prompt/prompt-followup.js +7 -1
  58. package/dist/src/report/report-data.js +5 -1
  59. package/dist/src/report/report-render.js +46 -0
  60. package/dist/src/review/protected-edits.js +32 -0
  61. package/dist/src/review/review.js +28 -7
  62. package/dist/src/roles/role-catalog.js +17 -5
  63. package/dist/src/roles/roles.js +2 -1
  64. package/dist/src/scheduling/claims.js +10 -1
  65. package/dist/src/scheduling/scheduling.js +17 -1
  66. package/dist/src/status/status-data.js +10 -5
  67. package/dist/src/status/status-polls.js +25 -1
  68. package/dist/src/tick/stage-check.js +5 -1
  69. package/dist/src/tick/tick-detail-data.js +15 -2
  70. package/dist/src/tick/tick-detail.js +38 -10
  71. package/dist/src/tick/tick-stage.js +32 -2
  72. package/dist/src/tick/tick-verdict.js +1 -0
  73. package/dist/src/ui/badges.js +7 -0
  74. package/dist/src/ui/gui/gui-client-boot.js +10 -4
  75. package/dist/src/ui/gui/gui-client-fleet.js +9 -1
  76. package/dist/src/ui/gui/gui-client-history.js +2 -1
  77. package/dist/src/ui/gui/gui-client-loops.js +4 -5
  78. package/dist/src/ui/gui/gui-client-model.js +4 -1
  79. package/dist/src/ui/gui/gui-client-operator.js +61 -1
  80. package/dist/src/ui/gui/gui-client-pending.js +4 -3
  81. package/dist/src/ui/gui/gui-client-report.js +15 -5
  82. package/dist/src/ui/gui/gui-client-settings.js +27 -1
  83. package/dist/src/ui/gui/gui-page.js +1 -0
  84. package/dist/src/ui/status-model.js +24 -36
  85. package/dist/src/ui/status-payload.js +10 -1
  86. package/dist/src/ui/status-render.js +2 -1
  87. package/dist/src/ui/tone.js +3 -1
  88. package/dist/src/ui/tui/tui-frame.js +9 -1
  89. package/dist/src/ui/tui/tui-input.js +11 -0
  90. package/dist/src/ui/tui/tui-keys.js +129 -8
  91. package/dist/src/ui/tui/tui-pane.js +15 -1
  92. package/dist/src/ui/tui/tui.js +4 -2
  93. package/dist/src/verdict/fix-claim.js +98 -18
  94. package/dist/src/verdict/refusal.js +54 -2
  95. package/package.json +1 -1
@@ -9,18 +9,22 @@ import { fail, say } from "./cli-output.js";
9
9
  import { durationLabel, failOverDurationCap, failRivalShapes, flagValue, parseBranchFlag, parseDurationFlag, parseRoleFlag, } from "./cli-args.js";
10
10
  import { PAUSE_FOR_MAX_MS } from "../operator/operator-intent.js";
11
11
  import { parseInitArgs } from "./cli-command-args.js";
12
- import { isFleetPaused, pausedRoles } from "../fleet/fleet-state.js";
12
+ import { cancelScheduledStop, isFleetPaused, pausedRoles, scheduledStopAt } from "../fleet/fleet-state.js";
13
13
  import { orchestratorAlive, readOrchestratorInfo } from "../fleet/orchestrator-info.js";
14
14
  import { runStartupCheck, runStartupProblem } from "../gates/startup-gate.js";
15
15
  import { initProject } from "../init/init.js";
16
16
  import { templateCatalog } from "../init/init-templates.js";
17
17
  import { repairInterruptedSwap } from "../build/build-stage.js";
18
18
  import { logEvent, subscribeEvents } from "../events/events.js";
19
+ import { notifyFleetDown } from "../events/notify.js";
19
20
  import { formatEvent } from "../events/event-format.js";
20
21
  import { RESTART_EXIT_CODE } from "../redeploy/redeploy-policy.js";
21
22
  import { loadLoopState, stateSkipReason } from "../loop/loop-state.js";
22
23
  import { plural } from "../text/phrases.js";
23
24
  import { shortSha } from "../text/format.js";
25
+ import { readTickRowsSince, summarizeTickRows } from "../history/history-data.js";
26
+ import { renderSummary } from "../history/history.js";
27
+ import { SPARSE_WINDOW_NOTE } from "../events/event-window.js";
24
28
  /** `tumwater init`: seed a project directory from the operator's brief (src/init/init.ts does the
25
29
  * work; this prints the report). --dry-run prints the would-be actions without writing
26
30
  * anything; an already-initialized repo is reported as a no-op, never an error. */
@@ -73,8 +77,9 @@ export async function cmdInit(root, args) {
73
77
  * `--for <duration>` keeps the daemon shape (redeployer, LaunchServicesWatch, supervisor hand-off
74
78
  * all stay) but arms a deadline: at the deadline the same graceful stop a Ctrl+C runs fires, and
75
79
  * the process exits after printing the summary. A mid-run self-redeploy hands the same args
76
- * (including `--for`) to the supervisor's next child, so the deadline restarts in the new
77
- * generation — the window is per-generation, stated here rather than coded around. */
80
+ * (including `--for`) to the supervisor's next child, and the supervisor hands every generation
81
+ * the same window start (RUN_WINDOW_START_ENV), so the deadline and the summary span the whole
82
+ * window; only the final exit prints the summary. */
78
83
  export async function cmdRun(root, args) {
79
84
  // The one function the self-redeploy asks before swapping onto a successor and the supervisor
80
85
  // asks when a generation dies, so the three cannot disagree about what boots. The supervisor
@@ -114,11 +119,13 @@ export async function cmdRun(root, args) {
114
119
  // compile, and the supervisor/redeploy/launch-services pieces ride beside it. Loading them
115
120
  // dynamically, after the fail-fast gates above, keeps every other command's spawn — and
116
121
  // `run`'s fail-fast paths — off that cost (see cli.ts's matching lazy dispatch).
117
- const { SUPERVISED_ENV, startParentDeathWatch, expectedSupervisorPid } = await import("../process/supervisor.js");
122
+ const { SUPERVISED_ENV, RUN_WINDOW_START_ENV, ONCE_FAILED_EXIT_CODE, startParentDeathWatch, expectedSupervisorPid } = await import("../process/supervisor.js");
118
123
  if (!process.env[SUPERVISED_ENV]) {
119
124
  // `args` are the flags after the command token — forward them so the child generation
120
125
  // targets the same branch (or whatever else the invocation named).
121
- await superviseRunCommand(root, args, branchArg);
126
+ // A stop marker left by a fleet that died before its deadline must not stop this run.
127
+ cancelScheduledStop(root);
128
+ await superviseRunCommand(root, args, branchArg, forMs);
122
129
  return;
123
130
  }
124
131
  const controller = new AbortController();
@@ -135,10 +142,15 @@ export async function cmdRun(root, args) {
135
142
  // A windowed run's deadline arms the same graceful stop a Ctrl+C runs — the identical `stop`
136
143
  // closure, so the drain path is one code path either way. clearTimeout in the finally below
137
144
  // retracts it when the run ends first (an early Ctrl+C), so no stray timer outlives the run.
138
- const deadlineTimer = forMs !== null ? armDeadlineTimer(forMs, () => {
145
+ const windowStart = inheritedWindowStart(process.env[RUN_WINDOW_START_ENV], Date.now());
146
+ const deadlineTimer = forMs !== null ? armDeadlineTimer(Math.max(0, windowStart + forMs - Date.now()), () => {
139
147
  say("deadline reached — stopping");
140
148
  stop();
141
149
  }) : null;
150
+ const scheduledStopWatch = armScheduledStopWatch(root, () => {
151
+ if (!stopping)
152
+ stop(); // A drain already under way must not be turned into a force-kill.
153
+ });
142
154
  // A supervisor that dies without forwarding — SIGKILL (the OOM killer, `kill -9`) cannot be
143
155
  // trapped or forwarded, and an uncaught supervisor crash forwards nothing either — leaves this
144
156
  // generation reparented and ticking the fleet unattended. The parent-death watch polls for the
@@ -188,20 +200,56 @@ export async function cmdRun(root, args) {
188
200
  launchServicesWatch,
189
201
  once,
190
202
  roleFilter: roleFilter ?? undefined,
203
+ stopsAt: forMs !== null ? windowStart + forMs : undefined,
191
204
  });
192
205
  }
193
206
  finally {
194
207
  if (deadlineTimer)
195
208
  deadlineTimer.clear();
209
+ scheduledStopWatch.clear();
196
210
  unsubscribe();
197
211
  }
198
- if (once || forMs !== null)
212
+ if (once)
199
213
  say(onceSummary(root, roles, ticksBefore, exit.settled, exit.ticksRun));
214
+ else if (forMs !== null && !exit.restart) {
215
+ const { rows, covered } = readTickRowsSince(root, Date.now() - windowStart, null);
216
+ say(windowSummary(durationLabel(forMs), summarizeTickRows(rows), covered));
217
+ }
218
+ if (once) {
219
+ const failures = onceFailures(root, roles, ticksBefore, exit.ticksRun);
220
+ if (failures.length > 0) {
221
+ say(`once: failed — ${failures.map((f) => `${f.role} ${f.result}`).join(", ")}`);
222
+ process.exitCode = ONCE_FAILED_EXIT_CODE;
223
+ }
224
+ }
200
225
  // A self-redeploy swapped the new build into dist/: hand the terminal back to the supervisor,
201
226
  // which respawns this same script — now the new code — as the next generation.
202
227
  if (exit.restart)
203
228
  process.exit(RESTART_EXIT_CODE);
204
229
  }
230
+ /** The window start a generation adopts: the supervisor's RUN_WINDOW_START_ENV mark when it is a
231
+ * positive integer no later than `now`, else `now` (absent, malformed, or in the future). */
232
+ export function inheritedWindowStart(raw, now) {
233
+ const n = Number(raw);
234
+ return raw !== undefined && Number.isInteger(n) && n > 0 && n <= now ? n : now;
235
+ }
236
+ /** The `run --for` exit summary: a window line, then the `history --summary` table for the ticks
237
+ * that ended since the window opened — every tick counted once by its outcome class. Only the
238
+ * final exit prints it; a mid-window self-redeploy hands off silently and the next generation
239
+ * inherits the window start, so the table spans every generation. */
240
+ export function windowSummary(label, summary, covered) {
241
+ const { total } = summary;
242
+ const lines = [
243
+ total.ticks === 0
244
+ ? `for ${label}: 0 ticks — nothing ran`
245
+ : `for ${label}: ${plural(total.ticks, "tick")} — ${total.changes} changes, ${total.none} none, ${total.problems} problems`,
246
+ ];
247
+ if (total.ticks > 0)
248
+ lines.push(renderSummary(summary));
249
+ if (!covered)
250
+ lines.push(SPARSE_WINDOW_NOTE);
251
+ return lines.join("\n");
252
+ }
205
253
  /** Arm the windowed run's deadline timer: the one home of the arming `cmdRun` does for
206
254
  * `run --for <duration>`. A duration over setTimeout's own 32-bit signed ceiling
207
255
  * (2_147_483_647 ms ≈ 24.8 days) is clamped by Node to 1ms with a TimeoutOverflowWarning, so
@@ -228,6 +276,23 @@ export function armDeadlineTimer(ms, onFired) {
228
276
  arm(ms);
229
277
  return { clear: () => clearTimeout(timer) };
230
278
  }
279
+ const SCHEDULED_STOP_POLL_MS = 5000;
280
+ /** Poll for a `tumwater stop --in` marker: once its `until` has passed, consume the marker,
281
+ * stop polling, and call `onStop` (cmdRun's Ctrl+C closure) exactly once. The interval is
282
+ * unref'd; clear() retracts it. Exported for tests. */
283
+ export function armScheduledStopWatch(root, onStop) {
284
+ const timer = setInterval(() => {
285
+ const at = scheduledStopAt(root);
286
+ if (at === undefined || at > Date.now())
287
+ return;
288
+ clearInterval(timer);
289
+ cancelScheduledStop(root);
290
+ say("scheduled stop reached — stopping");
291
+ onStop();
292
+ }, SCHEDULED_STOP_POLL_MS);
293
+ timer.unref();
294
+ return { clear: () => clearInterval(timer) };
295
+ }
231
296
  /** Parse `run --for <duration>` (the windowed run): the parsed duration in milliseconds, or null
232
297
  * when the flag is absent. The two body-level rules fail fast, before any repo gate or boot: the
233
298
  * value must parse as a duration (parseDurationFlag's own wording, the same messages the
@@ -245,6 +310,32 @@ export function parseRunWindow(args, once) {
245
310
  failOverDurationCap("--for", ms, PAUSE_FOR_MAX_MS);
246
311
  return ms;
247
312
  }
313
+ /** Results that mean the harness or the project's check failed and the round's work did not
314
+ * land: a `--once` round with a role ending in one exits ONCE_FAILED_EXIT_CODE. `rejected` and
315
+ * `refused` are the review gate and the agent working as designed; `aborted`, `quiet_killed`, and
316
+ * `user_aborted` only reach a once round on a shutdown or operator abort, which exit their own way. */
317
+ export const ONCE_FAILURE_RESULTS = new Set([
318
+ "error",
319
+ "review_error",
320
+ "merge_conflict",
321
+ "merge_blocked",
322
+ "main_red",
323
+ ]);
324
+ function ticksThisRound(s, role, ticksBefore, ticksRun) {
325
+ return ticksRun ? (ticksRun.get(role) ?? 0) : s.ticks - (ticksBefore.get(role) ?? 0);
326
+ }
327
+ /** The roles that ticked this round and whose last result is in ONCE_FAILURE_RESULTS. */
328
+ export function onceFailures(root, roles, ticksBefore, ticksRun) {
329
+ const out = [];
330
+ const extra = ticksRun ? [...ticksRun.keys()].filter((r) => !roles.includes(r)) : [];
331
+ for (const role of [...roles, ...extra]) {
332
+ const s = loadLoopState(root, role);
333
+ if (ticksThisRound(s, role, ticksBefore, ticksRun) > 0 && s.lastResult && ONCE_FAILURE_RESULTS.has(s.lastResult)) {
334
+ out.push({ role, result: s.lastResult });
335
+ }
336
+ }
337
+ return out;
338
+ }
248
339
  /** The once round's one-line summary, read from the runners' persisted loop state plus the
249
340
  * orchestrator's own settle reasons — a cron job's log shows what the round did without parsing
250
341
  * events. Roles whose tick counter advanced bucket by their last completed result; the rest are
@@ -271,7 +362,7 @@ export function onceSummary(root, roles, ticksBefore, settled, ticksRun) {
271
362
  const extra = ticksRun ? [...ticksRun.keys()].filter((r) => !roles.includes(r)) : [];
272
363
  for (const role of [...roles, ...extra]) {
273
364
  const s = loadLoopState(root, role);
274
- const ran = ticksRun ? (ticksRun.get(role) ?? 0) : s.ticks - (ticksBefore.get(role) ?? 0);
365
+ const ran = ticksThisRound(s, role, ticksBefore, ticksRun);
275
366
  if (ran > 0) {
276
367
  ticks += ran;
277
368
  const key = s.lastResult ?? "no_change";
@@ -326,11 +417,12 @@ async function superviseDashboard(signal) {
326
417
  * supervisor's own exit code is whatever the last generation's was. A fleet that goes down without
327
418
  * the operator asking leaves a `supervisor_exit` event behind (BUGS.md 2026-09-23): the dead
328
419
  * generation's stderr reached only this terminal, which nobody may be watching. */
329
- async function superviseRunCommand(root, runArgs, branchArg) {
420
+ async function superviseRunCommand(root, runArgs, branchArg, forMs) {
330
421
  // The supervisor machinery loads lazily, like cmdRun's orchestrator import above: a command
331
422
  // that never boots the fleet never compiles it.
332
- const { fleetDownEvent, spawnRunChild, superviseRun } = await import("../process/supervisor.js");
423
+ const { RUN_WINDOW_START_ENV, fleetDownEvent, spawnRunChild, superviseRun } = await import("../process/supervisor.js");
333
424
  const plan = guiChildPlan(runArgs);
425
+ const windowEnv = forMs !== null ? { [RUN_WINDOW_START_ENV]: String(Date.now()) } : {};
334
426
  const controller = new AbortController();
335
427
  // The dashboard lives beside the orchestrator generations rather than inside one: it must
336
428
  // outlive every generation a redeploy swaps out. Its own abort stops the current dashboard
@@ -347,7 +439,7 @@ async function superviseRunCommand(root, runArgs, branchArg) {
347
439
  });
348
440
  const gui = plan.spawnGui ? superviseDashboard(guiController.signal) : Promise.resolve();
349
441
  const code = await superviseRun({
350
- spawnChild: (signal) => spawnRunChild(signal, plan.orchestratorArgs),
442
+ spawnChild: (signal) => spawnRunChild(signal, plan.orchestratorArgs, windowEnv),
351
443
  stopping: () => stopping,
352
444
  onRespawn: (generation) => say(`\nrestarting on the new build (generation ${generation})\n`),
353
445
  onCrashLoop: () => process.stderr.write("tumwater: the harness restarted itself too many times in a minute — giving up\n"),
@@ -356,7 +448,8 @@ async function superviseRunCommand(root, runArgs, branchArg) {
356
448
  // tumwater.json gone). A crash loop's generations all asked for restarts, so the gate
357
449
  // has nothing to say about it.
358
450
  onFleetDown: async (down) => {
359
- logEvent(root, fleetDownEvent(down, down.crashLoop ? null : await runStartupProblem(root, branchArg)));
451
+ const event = logEvent(root, fleetDownEvent(down, down.crashLoop ? null : await runStartupProblem(root, branchArg)));
452
+ notifyFleetDown(root, event);
360
453
  },
361
454
  // The end-of-supervision build repair (see build-stage.repairInterruptedSwap): a swap
362
455
  // killed between its two renames leaves dist/ missing, and this process is the only one
@@ -0,0 +1,149 @@
1
+ /** Shell completion derived from the help text. The spec is parsed from the same HELP stanzas
2
+ * `tumwater help <command>` and the unknown-command suggestion use, so a new command or flag
3
+ * completes the moment it gains a help stanza and can never drift from the listing. */
4
+ import { customLoopNames, knownRoleIds, loadConfig } from "../config/config.js";
5
+ import { AGENT_IDS, AGENT_KEYS, MODEL_TIERS, ROLE_ENTRY_KEYS, TOP_LEVEL_KEYS } from "../config/config-schema.js";
6
+ import { DOTTED_MAP_KEYS } from "../config/config-write.js";
7
+ import { HISTORY_RESULT_CLASSES } from "../history/history-data.js";
8
+ import { templateIds } from "../init/init-templates.js";
9
+ import { allRoleIds } from "../roles/roles.js";
10
+ import { HELP, helpStanzas } from "./help.js";
11
+ export const SUPPORTED_SHELLS = ["bash", "zsh"];
12
+ /** The completable words of a stanza's usage part: the text after `tumwater <command>` up to the
13
+ * description column (two or more spaces). Bare lowercase words count inside `[...]` and as the
14
+ * leading run directly after the command; outside brackets the first other token ends the run, so
15
+ * a single-space description (`role <id> --clear-notes Clear that notebook`) adds nothing. */
16
+ function usageWords(firstLine, command) {
17
+ const rest = firstLine.replace(new RegExp(`^ {2}tumwater ${command}`), "");
18
+ // Usage arguments follow the command after a single space; two or more spaces start the description.
19
+ const usage = /^ {2,}/.test(rest) ? "" : (rest.split(/ {2,}/)[0] ?? "");
20
+ const words = [];
21
+ let depth = 0;
22
+ let leading = true;
23
+ for (const token of usage.split(/\s+/).filter(Boolean)) {
24
+ const bare = token.replace(/[[\]|]/g, "");
25
+ const isWord = /^[a-z][a-z-]*$/.test(bare);
26
+ if (isWord && (depth > 0 || token.startsWith("[") || leading))
27
+ words.push(bare);
28
+ else if (depth === 0 && !token.startsWith("[") && bare !== "")
29
+ leading = false;
30
+ for (const ch of token)
31
+ depth += ch === "[" ? 1 : ch === "]" ? -1 : 0;
32
+ }
33
+ return words;
34
+ }
35
+ export function completionSpec(help = HELP) {
36
+ const stanzas = helpStanzas(help);
37
+ const commands = [...new Set(stanzas.map((s) => s.command))];
38
+ const subcommands = {};
39
+ const flags = {};
40
+ for (const command of commands) {
41
+ const own = stanzas.filter((s) => s.command === command);
42
+ const flagSet = new Set(["--help"]);
43
+ const subs = new Set();
44
+ for (const stanza of own) {
45
+ for (const match of stanza.text.matchAll(/--[a-z][a-z-]*/g))
46
+ flagSet.add(match[0]);
47
+ for (const word of usageWords(stanza.text.split("\n")[0] ?? "", command))
48
+ subs.add(word);
49
+ }
50
+ flags[command] = [...flagSet].sort();
51
+ subcommands[command] = command === "help" ? commands : [...subs];
52
+ }
53
+ return { commands, subcommands, flags };
54
+ }
55
+ /** The ids `--role` completes: this repo's loop ids, or the built-in catalog when tumwater.json is
56
+ * unreadable (a broken config must not write errors into the operator's prompt). */
57
+ export function completionRoleIds(root) {
58
+ try {
59
+ return knownRoleIds(loadConfig(root));
60
+ }
61
+ catch {
62
+ return allRoleIds();
63
+ }
64
+ }
65
+ /** The keys `config get|set|unset` complete: every form parseConfigKey accepts, with the role-scoped
66
+ * keys of this repo's base roles and custom loops (not instance ids like `feature-2`). A broken
67
+ * tumwater.json falls back to the built-in catalog rather than writing errors into the prompt. */
68
+ export function completionConfigKeys(root) {
69
+ let roleIds;
70
+ try {
71
+ roleIds = [...allRoleIds(), ...customLoopNames(loadConfig(root))];
72
+ }
73
+ catch {
74
+ roleIds = allRoleIds();
75
+ }
76
+ const keys = [
77
+ ...TOP_LEVEL_KEYS,
78
+ ...MODEL_TIERS.map((tier) => `model.${tier}`),
79
+ ...AGENT_IDS.flatMap((id) => AGENT_KEYS.map((field) => `agents.${id}.${field}`)),
80
+ ...DOTTED_MAP_KEYS.flatMap((map) => roleIds.map((id) => `${map}.${id}`)),
81
+ ...roleIds.flatMap((id) => ROLE_ENTRY_KEYS.map((field) => `roles.${id}.${field}`)),
82
+ ];
83
+ return [...new Set(keys)];
84
+ }
85
+ /** Flags whose value is a file path: completion offers file names after them. */
86
+ const PATH_FLAGS = ["--file", "--attach", "--notes-file"];
87
+ function bashScript(help) {
88
+ const spec = completionSpec(help);
89
+ const arms = spec.commands.map((command) => {
90
+ const subs = spec.subcommands[command] ?? [];
91
+ const flags = spec.flags[command] ?? [];
92
+ return [
93
+ ` ${command})`,
94
+ ` if [[ "$cur" == -* ]]; then`,
95
+ ` COMPREPLY=( $(compgen -W "${flags.join(" ")}" -- "$cur") )`,
96
+ ...(subs.length > 0
97
+ ? [
98
+ ` elif [ "$COMP_CWORD" -eq 2 ]; then`,
99
+ ` COMPREPLY=( $(compgen -W "${subs.join(" ")}" -- "$cur") )`,
100
+ ]
101
+ : []),
102
+ ` fi`,
103
+ ` ;;`,
104
+ ].join("\n");
105
+ });
106
+ return [
107
+ `# tumwater completion (generated from \`tumwater help\`)`,
108
+ `_tumwater() {`,
109
+ ` local cur="\${COMP_WORDS[COMP_CWORD]}"`,
110
+ ` local prev="\${COMP_WORDS[COMP_CWORD-1]}"`,
111
+ ` COMPREPLY=()`,
112
+ ` if [ "$prev" = "--role" ] || { [ "$COMP_CWORD" -eq 2 ] && [[ "\${COMP_WORDS[1]}" == role || "\${COMP_WORDS[1]}" == tick ]] && [[ "$cur" != -* ]]; }; then`,
113
+ ` COMPREPLY=( $(compgen -W "$(tumwater completion --roles 2>/dev/null)" -- "$cur") )`,
114
+ ` return 0`,
115
+ ` fi`,
116
+ ` case "$prev" in ${PATH_FLAGS.join("|")})`,
117
+ ` COMPREPLY=( $(compgen -f -- "$cur") )`,
118
+ ` return 0`,
119
+ ` ;;`,
120
+ ` esac`,
121
+ ` if [ "$prev" = "--template" ]; then`,
122
+ ` COMPREPLY=( $(compgen -W "${templateIds().join(" ")}" -- "$cur") )`,
123
+ ` return 0`,
124
+ ` fi`,
125
+ ` if [ "$prev" = "--result" ]; then`,
126
+ ` COMPREPLY=( $(compgen -W "${HISTORY_RESULT_CLASSES.join(" ")}" -- "$cur") )`,
127
+ ` return 0`,
128
+ ` fi`,
129
+ ` if [ "$COMP_CWORD" -eq 3 ] && [ "\${COMP_WORDS[1]}" = config ] && [[ "\${COMP_WORDS[2]}" == get || "\${COMP_WORDS[2]}" == set || "\${COMP_WORDS[2]}" == unset ]] && [[ "$cur" != -* ]]; then`,
130
+ ` COMPREPLY=( $(compgen -W "$(tumwater completion --config-keys 2>/dev/null)" -- "$cur") )`,
131
+ ` return 0`,
132
+ ` fi`,
133
+ ` if [ "$COMP_CWORD" -eq 1 ]; then`,
134
+ ` COMPREPLY=( $(compgen -W "${spec.commands.join(" ")}" -- "$cur") )`,
135
+ ` return 0`,
136
+ ` fi`,
137
+ ` case "\${COMP_WORDS[1]}" in`,
138
+ ...arms,
139
+ ` esac`,
140
+ ` return 0`,
141
+ `}`,
142
+ `complete -F _tumwater tumwater`,
143
+ ``,
144
+ ].join("\n");
145
+ }
146
+ export function completionScript(shell, help = HELP) {
147
+ const bash = bashScript(help);
148
+ return shell === "zsh" ? `autoload -U +X bashcompinit && bashcompinit\n${bash}` : bash;
149
+ }
@@ -15,8 +15,11 @@ Usage:
15
15
  Run all enabled loops (headless; Ctrl+C stops);
16
16
  --once runs one full round of ticks, drains the
17
17
  landings it produced, and exits (for cron/CI);
18
+ exits 3 when a tick ended in error, review_error,
19
+ merge_conflict, merge_blocked, or main_red;
18
20
  --once --role <id> scopes that round to one loop;
19
- --for <duration> runs for that window then drains
21
+ --for <duration> runs for that window then drains, prints a per-loop summary
22
+ (one window across self-redeploys),
20
23
  and exits like Ctrl+C would (for an overnight
21
24
  trial, a demo, a CI step);
22
25
  --gui also serves the browser dashboard at
@@ -88,7 +91,7 @@ Usage:
88
91
  tumwater notify --test Run the configured notify hook once with a synthetic event
89
92
  so the command can be checked before a real page
90
93
  tumwater history [--role <id>] [-n N] [--since <duration>] [--grep <text>]
91
- [--result <changes|none|problems>] [--json]
94
+ [--result <changes|none|problems>] [--summary] [--json]
92
95
  One row per completed tick, newest first — time, loop,
93
96
  tick number, result, duration, tokens/cost, and the
94
97
  summary (or error), each row one line; --since <duration>
@@ -100,30 +103,37 @@ Usage:
100
103
  problems (everything else) — composed with --role, --since,
101
104
  -n, and --grep; --json prints the rows as
102
105
  machine-readable history data — ts, tokens, and costUsd
103
- kept raw, the GUI's /api/history payload
104
- tumwater tick <role> [<n>] [--last] [--json]
106
+ kept raw, the GUI's /api/history payload; --summary prints one line per loop
107
+ counting its ticks by outcome class, with the time of its newest change (and a total line)
108
+ instead of the rows; with --json, { loops, total }
109
+ tumwater tick <role> [<n>] [--last] [--stat] [--json]
105
110
  One completed tick's full event trail — a summary header
106
111
  (result or in-flight, duration, tokens/cost, the pinned
107
112
  commit sha) then every event of that tick's block in time
108
113
  order; a still-running tick shows its events so far, marked
109
- in flight; --last shows the newest completed tick's trail
110
- instead of numbering one; --json prints the collector's
111
- payload as machine-readable data
114
+ in flight; a queued tick shows its landing's verdict and
115
+ outcome line once the landing finishes; --last shows the newest completed tick's trail
116
+ instead of numbering one; --stat appends the pinned
117
+ commit's per-file git show --stat summary; --json prints
118
+ the collector's payload as machine-readable data
112
119
  tumwater diff [--json] One line per loop holding pending work — the ahead-of-main
113
120
  commit count and uncommitted-file count, no patch; roles
114
121
  with no worktree or nothing pending are skipped, and a
115
122
  missing baseline prints the per-role view's degradation
116
123
  line; --json prints the {mainBranch, roles} roster
117
- tumwater diff --role <id> [--json]
124
+ tumwater diff --role <id> [--stat] [--json]
118
125
  Show the change that loop holds: its branch's unlanded
119
126
  commits (one line each, plus the ahead-of-main patch) and
120
127
  its worktree's uncommitted edits (the file list, plus the
121
128
  patch — staged and unstaged alike); a loop with no worktree
122
- yet prints \`no worktree for <id>\`; --json prints the
129
+ yet prints \`no worktree for <id>\`; --stat prints each patch's per-file
130
+ \`git diff --stat\` summary instead; --json prints the
123
131
  collector's payload as machine-readable data
124
132
  tumwater queue [--json] List the durable land queue in execution order (oldest
125
133
  first): one line per waiting landing — position, loop, tick
126
- number, short pinned sha, age, and summary; an empty queue
134
+ number, short pinned sha, age, and summary — plus a bracketed
135
+ \`[landing 2m · reviewing]\` / \`[vetted, awaiting merge]\`
136
+ label while a running fleet works on it; an empty queue
127
137
  prints \`land queue empty\`, and any torn/foreign queue file
128
138
  the reader skipped is counted on a trailing line; --json
129
139
  prints the collector's own {entries, unreadable} document
@@ -140,6 +150,10 @@ Usage:
140
150
  File a bug from a markdown file ("-" reads stdin): the first
141
151
  non-blank line is the symptom, the rest is the body kept
142
152
  line for line; wakes the bugfix loop
153
+ tumwater bug --release <n> [--json]
154
+ Lift the Refused note from the Nth entry in BUGS.md ## Open
155
+ (as \`tumwater bug --list\` lists it), commit it on main,
156
+ and wake the bugfix loop; --json prints {file, title}
143
157
  tumwater bug --cancel <n> [--json]
144
158
  Remove the Nth entry in BUGS.md ## Open (as \`tumwater
145
159
  bug --list\` lists it); no wake; --json prints {file, title}
@@ -152,10 +166,17 @@ Usage:
152
166
  stdin): the first non-blank line is the title (symptom), the rest
153
167
  the body kept line for line; position and original stamp
154
168
  kept; no wake; --json prints {file, title}
169
+ tumwater bug --edit <n> [--json]
170
+ With neither text nor --file, open the Nth entry as --show
171
+ prints it in $VISUAL or $EDITOR and write it back on save;
172
+ an unchanged buffer writes nothing
155
173
  tumwater bug --list [--json] List the entries in BUGS.md ## Open, numbered (the read
156
174
  half of --edit/--cancel), each with its eligibility mark;
157
175
  --json prints the {entries} array — position, title, body,
158
176
  hold — as data
177
+ tumwater bug --show <n> [--json] Print the Nth entry as the markdown file --edit <n> --file
178
+ reads (title, blank line, body with indentation kept);
179
+ --json prints {position, title, body, hold}
159
180
  tumwater plan "<title>" [body...] [--json]
160
181
  File a plan request into PLANS.md ## Planned as the operator
161
182
  (stamped with today's date; the feature loop fleshes the stub
@@ -165,6 +186,10 @@ Usage:
165
186
  File a plan request from a markdown file ("-" reads stdin):
166
187
  the first non-blank line is the title, the rest is the body
167
188
  kept line for line; wakes the feature loop
189
+ tumwater plan --release <n> [--json]
190
+ Lift the Refused note from the Nth entry in PLANS.md ## Planned
191
+ (as \`tumwater plan --list\` lists it), commit it on main,
192
+ and wake the feature loop; --json prints {file, title}
168
193
  tumwater plan --cancel <n> [--json]
169
194
  Remove the Nth entry in PLANS.md ## Planned (as \`tumwater
170
195
  plan --list\` lists it); no wake; --json prints {file, title}
@@ -177,10 +202,18 @@ Usage:
177
202
  stdin): the first non-blank line is the title, the rest
178
203
  the body kept line for line; position and original stamp
179
204
  kept; no wake; --json prints {file, title}
205
+ tumwater plan --edit <n> [--json]
206
+ With neither text nor --file, open the Nth entry as --show
207
+ prints it in $VISUAL or $EDITOR and write it back on save;
208
+ an unchanged buffer writes nothing
180
209
  tumwater plan --list [--json] List the entries in PLANS.md ## Planned, numbered (the
181
210
  read half of --edit/--cancel), each with its eligibility
182
211
  mark; --json prints the {entries} array — position, title,
183
212
  body, hold — as data
213
+ tumwater plan --show <n> [--json]
214
+ Print the Nth entry as the markdown file --edit <n> --file
215
+ reads (title, blank line, body with indentation kept);
216
+ --json prints {position, title, body, hold}
184
217
  tumwater questions [--json] List open questions numbered (the read half of the
185
218
  question outbox); --json prints the {questions}
186
219
  array — position, title, body — as data
@@ -194,6 +227,17 @@ Usage:
194
227
  Read the decision from the file (- for stdin) with its
195
228
  lines kept; a ## or ### heading line outside a code fence,
196
229
  or an unclosed fence, is refused
230
+ tumwater questions answer [--json] <n>
231
+ With no decision, open the question in $VISUAL/$EDITOR;
232
+ only the text above the marker line is recorded
233
+ tumwater brief [--json] Show the project's initial prompt and the file that holds it
234
+ tumwater brief --file <path> [--json]
235
+ Replace the initial prompt with the file's text (- for
236
+ stdin), refused when empty, over the length cap, or
237
+ containing a tumwater:prompt marker; commits that one
238
+ file on main; no wake
239
+ tumwater brief --edit [--json] Open the initial prompt in $VISUAL/$EDITOR and save it as
240
+ --file would; an unchanged buffer writes nothing
197
241
  tumwater role <id> [--json] Show one loop's standing prompt and resolved settings — its
198
242
  find text, the roles.<id>.instructions override, the resolved
199
243
  provider/model (naming the budget fallback pair when one is
@@ -236,6 +280,10 @@ Usage:
236
280
  contents ("-" reads stdin), kept line for line; position,
237
281
  age, and a pending --at deferral are kept, and --role <id>
238
282
  disambiguates like --edit
283
+ tumwater prompt --edit <n> [--attach <path>]
284
+ Open the Nth queued prompt in $VISUAL/$EDITOR and save it
285
+ as the text form would; an unchanged buffer writes
286
+ nothing; --role <id> disambiguates like --edit
239
287
  tumwater reset-counters [--role <id>]
240
288
  Zero lifetime ticks/commits/tokens/cost (fresh
241
289
  observation window; today's budget spend is kept — the
@@ -258,8 +306,15 @@ Usage:
258
306
  tumwater resume [--role <id>] Lift a fleet or per-role pause
259
307
  tumwater retire --role <id> Remove a disabled loop's worktree, branch, and per-role
260
308
  state (landing ref, pause marker); --force overrides the
261
- safety rails; --json prints what was removed
262
- tumwater stop Stop a running fleet (drains in-flight ticks, like Ctrl+C)
309
+ safety rails; --json prints what was removed;
310
+ --dry-run lists what would be removed and any objections,
311
+ changing nothing
312
+ tumwater stop [--in <duration> | --cancel]
313
+ Stop a running fleet (drains in-flight ticks, like Ctrl+C);
314
+ --in schedules the stop, --cancel drops it
315
+ tumwater completion <bash|zsh> Print a shell completion script (eval "$(tumwater completion zsh)" in your rc)
316
+ tumwater completion --roles Print the loop ids --role completes (this repo's; the catalog when tumwater.json is unreadable)
317
+ tumwater completion --config-keys Print the keys config get/set/unset complete (this repo's role keys; the catalog's when tumwater.json is unreadable)
263
318
  tumwater help [<command>] Show all commands, or one command's usage
264
319
  tumwater version Print the version
265
320
 
@@ -309,13 +364,13 @@ export function helpTopic(command, help = HELP) {
309
364
  }
310
365
  /** The free-form text commands and the flags that take a separate value token, for
311
366
  * helpTopicForArgs: a valued flag's value is plumbing (`prompt --role qa --help`, `bug
312
- * --cancel 2 --help`), never prose. bug/plan's valued flags are `--cancel`, `--edit`
313
- * and `--file`. */
367
+ * --cancel 2 --help`), never prose. bug/plan's valued flags are `--cancel`, `--edit`,
368
+ * `--file` and `--release`. */
314
369
  const FREEFORM_VALUED_FLAGS = {
315
370
  prompt: ["--role", "--file", "--at", "--cancel", "--edit", "--attach"],
316
371
  init: ["--branch", "--template", "--file"],
317
- bug: ["--cancel", "--edit", "--file"],
318
- plan: ["--cancel", "--edit", "--file"],
372
+ bug: ["--cancel", "--edit", "--file", "--release"],
373
+ plan: ["--cancel", "--edit", "--file", "--release"],
319
374
  };
320
375
  /** The `--help`/`-h` request cli.ts answers before dispatch: the topic text for `command`
321
376
  * when the invocation asks for help and names a topic, otherwise null — not a help request,
@@ -117,6 +117,8 @@ function answeredOrFail(n, outcome) {
117
117
  fail(`no question at position ${n} (${outcome.open} open)`);
118
118
  if (outcome.status === "fenced")
119
119
  fail(UNCLOSED_FENCE_MESSAGE);
120
+ if (outcome.status === "moved")
121
+ fail(`question ${n} is now "${outcome.title}" — the list changed while you edited; nothing changed`);
120
122
  if (outcome.status !== "answered")
121
123
  fail(`no question at position ${n}`);
122
124
  return { title: outcome.title, decision: outcome.decision };
@@ -124,14 +126,28 @@ function answeredOrFail(n, outcome) {
124
126
  /** `tumwater questions answer <n> --file <path>`: the decision's lines kept (normalizeEntryBody),
125
127
  * behind the same structural guard `plan --file` uses, so a stray `## ` line cannot grow a
126
128
  * phantom section. Refusals exit 1 through `fail`. */
127
- export function answerQuestionFromText(root, n, text) {
129
+ export function answerQuestionFromText(root, n, text, expectedTitle) {
128
130
  const body = normalizeEntryBody(text);
129
131
  if (body === "")
130
132
  fail("questions answer needs a decision: the file is blank");
131
133
  const problem = bodyStructureProblem(body);
132
134
  if (problem !== null)
133
135
  fail(`questions answer --file: ${problem}`);
134
- return answeredOrFail(n, tryAnswerQuestion(root, n, body, undefined, true));
136
+ return answeredOrFail(n, tryAnswerQuestion(root, n, body, expectedTitle, true));
137
+ }
138
+ /** The line that ends the decision area of the `questions answer <n>` editor buffer. */
139
+ export const ANSWER_EDITOR_CUT = "<!-- tumwater: write your decision above this line; this line and everything below it are dropped -->";
140
+ /** The editor buffer for bare `questions answer <n>`: an empty decision area, the cut line, then
141
+ * the question's heading and body for reference. */
142
+ export function answerEditorBuffer(title, body) {
143
+ return ["", "", ANSWER_EDITOR_CUT, `### ${title}`, body].join("\n");
144
+ }
145
+ /** The decision an edited `answerEditorBuffer` holds: the text before the first cut line (all of
146
+ * it when the operator deleted the line). */
147
+ export function decisionFromEditorBuffer(text) {
148
+ const lines = text.split("\n");
149
+ const cut = lines.findIndex((l) => l.trim() === ANSWER_EDITOR_CUT);
150
+ return (cut === -1 ? lines : lines.slice(0, cut)).join("\n");
135
151
  }
136
152
  /** The non-exiting answer core, for callers (the dashboard server) that must not end the
137
153
  * process: runs the whole read-modify-write under the cross-process primary-worktree lock