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
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { INITIAL_PROMPT_MAX_CHARS, PROMPT_END, PROMPT_START, briefPath, replaceInitialPrompt } from "./brief.js";
4
+ import { commitOperatorBacklogEdit } from "./backlog/backlog-commit.js";
5
+ import { withStateLock } from "./concurrency/lock.js";
6
+ import { writeTextAtomic } from "./files/files.js";
7
+ import { stampedSubject } from "./git/commit-message.js";
8
+ import { primaryWorktreeLockPath } from "./paths.js";
9
+ import { PREFILL_REASON } from "./text/phrases.js";
10
+ import { tooLongMessage } from "./text/text.js";
11
+ const NO_BRIEF = "no project brief with a prompt block found — run `tumwater init` first";
12
+ /** Replace the initial prompt with `text` under the checks init applies (non-empty, at most
13
+ * INITIAL_PROMPT_MAX_CHARS, no prompt marker), write it under the primary-worktree lock, and
14
+ * commit that one file on main. The one writer behind `tumwater brief --file` and the
15
+ * dashboard's Settings card, so the two cannot drift. */
16
+ export async function editInitialPrompt(root, text) {
17
+ const prompt = text.trim();
18
+ if (prompt === "")
19
+ return { ok: false, error: "the new initial prompt is empty" };
20
+ if (prompt.length > INITIAL_PROMPT_MAX_CHARS)
21
+ return { ok: false, error: tooLongMessage("the new initial prompt", prompt.length, INITIAL_PROMPT_MAX_CHARS, PREFILL_REASON) };
22
+ if (prompt.includes(PROMPT_START) || prompt.includes(PROMPT_END))
23
+ return { ok: false, error: "the new initial prompt contains a tumwater:prompt marker, which would break the block for every reader" };
24
+ const file = briefPath(root);
25
+ if (file === null)
26
+ return { ok: false, error: NO_BRIEF };
27
+ const name = path.basename(file);
28
+ const changed = withStateLock(primaryWorktreeLockPath(root), () => {
29
+ const current = fs.readFileSync(file, "utf8");
30
+ const next = replaceInitialPrompt(current, prompt);
31
+ if (next === null)
32
+ return `${name} no longer carries a prompt block`;
33
+ if (next === current)
34
+ return false;
35
+ writeTextAtomic(file, next);
36
+ return true;
37
+ });
38
+ if (typeof changed === "string")
39
+ return { ok: false, error: changed };
40
+ const committed = changed && (await commitOperatorBacklogEdit(root, name, stampedSubject("operator", "Edit initial prompt")));
41
+ return { ok: true, file: name, changed, committed };
42
+ }
package/dist/src/brief.js CHANGED
@@ -101,14 +101,40 @@ export function briefFile(root) {
101
101
  export function readInitialPrompt(root) {
102
102
  return readBrief(root)?.prompt ?? "";
103
103
  }
104
- function extractPrompt(text) {
104
+ /** The full path of the file that owns the prompt block (the same candidate readBrief
105
+ * resolves), or null when none does — so a writer edits the file every reader reads. */
106
+ export function briefPath(root) {
107
+ const found = firstBriefText(root, ownsBrief);
108
+ return found ? path.join(root, found.file) : null;
109
+ }
110
+ /** `text` with the prompt block's content replaced by `prompt` (trimmed), every byte outside
111
+ * the two markers kept; null when the text has no well-formed block. Finds the block by
112
+ * extractPrompt's rule (first start marker, then the first end marker after it). */
113
+ export function replaceInitialPrompt(text, prompt) {
114
+ const start = text.indexOf(PROMPT_START);
115
+ if (start < 0)
116
+ return null;
117
+ const bodyStart = start + PROMPT_START.length;
118
+ const end = text.indexOf(PROMPT_END, bodyStart);
119
+ if (end < 0)
120
+ return null;
121
+ return `${text.slice(0, bodyStart)}\n${prompt.trim()}\n${text.slice(end)}`;
122
+ }
123
+ /** The trimmed, untruncated text between the first start marker and the first end marker after
124
+ * it; null when `text` has no well-formed block. */
125
+ export function promptBlockOf(text) {
105
126
  const start = text.indexOf(PROMPT_START);
106
127
  if (start < 0)
107
128
  return null;
108
129
  const end = text.indexOf(PROMPT_END, start + PROMPT_START.length);
109
130
  if (end < 0)
110
131
  return null;
111
- const prompt = text.slice(start + PROMPT_START.length, end).trim();
132
+ return text.slice(start + PROMPT_START.length, end).trim();
133
+ }
134
+ function extractPrompt(text) {
135
+ const prompt = promptBlockOf(text);
136
+ if (prompt === null)
137
+ return null;
112
138
  // A hand-edited brief can carry an over-long prompt past init's length check. Truncate it
113
139
  // for every tick instead of injecting the whole thing (the same defensive cap readPrinciples
114
140
  // applies, through text.ts's truncateWithNote), with a visible note so the loss is not
@@ -125,3 +151,8 @@ export function readBrief(root) {
125
151
  const found = firstBriefText(root, ownsBrief);
126
152
  return found ? { file: found.file, prompt: extractPrompt(found.text) ?? "" } : null;
127
153
  }
154
+ /** The brief's plain-text form: its file name, then the initial prompt — what `tumwater brief`
155
+ * prints and the TUI's Brief tab shows, so the two cannot drift. */
156
+ export function renderBrief(brief) {
157
+ return `${brief.file}\n${brief.prompt}`;
158
+ }
@@ -34,7 +34,7 @@ const DIFF_MAX_BYTES = 200_000;
34
34
  /** Collect the change the role's loop holds: both halves of the view, or a degraded state
35
35
  * when the worktree or the base branch is missing. Never throws on a degraded fleet — a
36
36
  * query about a loop that has not run yet must answer, not fail (report's rationale). */
37
- export async function collectRoleChange(root, role) {
37
+ export async function collectRoleChange(root, role, opts = {}) {
38
38
  const branch = branchName(role);
39
39
  const { config } = loadConfigSafe(root);
40
40
  const mainBranch = targetBranch(config?.baseBranch, await currentBranch(root));
@@ -59,9 +59,18 @@ export async function collectRoleChange(root, role) {
59
59
  return { ...empty, state: "absent" };
60
60
  const ahead = await aheadOfMain(wt, mainBranch);
61
61
  const commits = parseOnelineLog(await gitTry(wt, "log", "--oneline", `${mainBranch}..HEAD`));
62
- const diff = ahead === 0 ? "" : await aheadOfMainDiff(wt, mainBranch);
62
+ // Under opts.stat both halves hold the uncapped `git diff --stat` summary (one line per file).
63
+ const diff = ahead === 0
64
+ ? ""
65
+ : opts.stat
66
+ ? ((await gitTry(wt, "diff", "--stat", `${mainBranch}...HEAD`)) ?? "")
67
+ : await aheadOfMainDiff(wt, mainBranch);
63
68
  const dirtyFiles = await changedFiles(wt);
64
- const uncommittedDiff = dirtyFiles.length === 0 ? "" : await cappedWorkingDiff(wt);
69
+ const uncommittedDiff = dirtyFiles.length === 0
70
+ ? ""
71
+ : opts.stat
72
+ ? ((await gitTry(wt, "diff", "--stat", "HEAD")) ?? "")
73
+ : await cappedWorkingDiff(wt);
65
74
  return { ...empty, state: "ready", ahead, commits, diff, dirtyFiles, uncommittedDiff };
66
75
  }
67
76
  /** Parse `git log --oneline` output into {sha, subject} pairs — sha up to the first space,
@@ -0,0 +1,56 @@
1
+ import path from "node:path";
2
+ import { readBrief, renderBrief } from "../brief.js";
3
+ import { editInitialPrompt } from "../brief-edit.js";
4
+ import { editInEditor } from "./cli-editor.js";
5
+ import { readPromptFileArg } from "./cli-command-args.js";
6
+ import { JSON_FLAG, rejectUnknownArgs } from "./cli-flag-specs.js";
7
+ import { fail, say, sayJson } from "./cli-output.js";
8
+ import { requireReadyRepo } from "./cli-repo-gate.js";
9
+ /** `brief`'s `--file <path>` flag: the replacement prompt (`-` for stdin). */
10
+ const FILE_SPEC = { names: ["--file"], value: true, valueName: "<path>" };
11
+ /** `brief`'s `--edit` flag: open the prompt in `$VISUAL`/`$EDITOR`. */
12
+ const EDIT_SPEC = { names: ["--edit"] };
13
+ /** `tumwater brief`: show the initial prompt and the file holding it, or with `--file <path>`
14
+ * replace it (or with `--edit`, open it in the editor) under the checks init applies and commit that one file on main. */
15
+ export async function cmdBrief(root, args) {
16
+ rejectUnknownArgs("brief", args, [JSON_FLAG, FILE_SPEC, EDIT_SPEC]);
17
+ await requireReadyRepo(root);
18
+ const json = args.includes("--json");
19
+ const fileIdx = args.indexOf("--file");
20
+ const edit = args.includes("--edit");
21
+ if (edit && fileIdx >= 0)
22
+ fail("pass --edit or --file, not both");
23
+ const brief = readBrief(root);
24
+ if (brief === null)
25
+ fail("no project brief with a prompt block found — run `tumwater init` first");
26
+ if (fileIdx < 0 && !edit) {
27
+ if (json)
28
+ sayJson({ file: brief.file, prompt: brief.prompt });
29
+ else
30
+ say(renderBrief(brief));
31
+ return;
32
+ }
33
+ let text;
34
+ if (edit) {
35
+ text = editInEditor(brief.prompt, "brief.md", "pass --file <path>");
36
+ if (text.trim() === brief.prompt.trim()) {
37
+ if (json)
38
+ sayJson({ file: path.basename(brief.file), changed: false, committed: false });
39
+ else
40
+ say("initial prompt unchanged");
41
+ return;
42
+ }
43
+ }
44
+ else
45
+ text = readPromptFileArg(args[fileIdx + 1]);
46
+ const outcome = await editInitialPrompt(root, text);
47
+ if (!outcome.ok)
48
+ fail(outcome.error);
49
+ const { file: name, changed, committed } = outcome;
50
+ if (json)
51
+ sayJson({ file: name, changed, committed });
52
+ else if (!changed)
53
+ say("initial prompt unchanged");
54
+ else
55
+ say(`initial prompt updated in ${name}${committed ? " (committed to main)" : ""}`);
56
+ }
@@ -307,7 +307,7 @@ export function parsePromptArgs(args) {
307
307
  // claims. Tokens preceding --edit (other than a claimed flag) would be silently dropped —
308
308
  // a mistyped command would edit prompt N with only the trailing fragment — so they are
309
309
  // refused here by the same stray-argument wording the sibling modes use; tokens after the
310
- // position are the replacement text, minus any claimed attach pair.
310
+ // position are the replacement text, minus any claimed attach pair (none: edit in $EDITOR).
311
311
  const fileClaim = fileFlag >= 0 ? [fileFlag, fileFlag + 1] : [];
312
312
  for (let i = 0; i < editFlag; i++) {
313
313
  if (roleClaim.includes(i) || attachClaims.includes(i) || fileClaim.includes(i))
@@ -328,9 +328,8 @@ export function parsePromptArgs(args) {
328
328
  textTokens.push(args[i]);
329
329
  }
330
330
  const text = textTokens.join(" ").trim();
331
- if (!text)
332
- fail('edit text required — usage: tumwater prompt --edit <n> "<new text>" (add --role <id> to aim it at one loop)');
333
- return { mode: "edit", role, position: n, text, attachPaths };
331
+ // No text and no --file: the caller opens the prompt's current body in the editor.
332
+ return { mode: "edit", role, position: n, text: text === "" ? null : text, attachPaths };
334
333
  }
335
334
  if (fileFlag >= 0) {
336
335
  // The file is the prompt: read it whole (a path of `-` reads stdin, so a pipe or heredoc
@@ -0,0 +1,52 @@
1
+ // Open text in the operator's editor, the way `git commit` and `crontab -e` do.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fail } from "./cli-output.js";
7
+ /** Signal number → name, for the shell's 128+n convention: a shell that does not re-raise a
8
+ * signal-killed child (dash on Linux) exits with 128+signo, so node sees an exit status where
9
+ * bash on macOS lets it see the signal. Keyed by number, so a later alias (SIGABRT over SIGIOT)
10
+ * wins; any name is fine for the message. */
11
+ const SIGNAL_NAMES = Object.fromEntries(Object.entries(os.constants.signals).map(([name, n]) => [n, name]));
12
+ /** How an editor run ended, naming a signal even when the shell folded it into 128+n. A null
13
+ * status (spawn error, signal) and a plain non-zero status fall through unchanged. */
14
+ function exitReason(r) {
15
+ if (r.error)
16
+ return r.error.message;
17
+ if (r.signal)
18
+ return `signal ${r.signal}`;
19
+ const signalled = r.status !== null && r.status > 128 ? SIGNAL_NAMES[r.status - 128] : undefined;
20
+ return signalled ? `signal ${signalled}` : `status ${r.status}`;
21
+ }
22
+ /** Write `initial` to a temp file named `fileName`, run `$VISUAL` (else `$EDITOR`) on it, and
23
+ * return the saved text. The editor value runs through `sh -c` (git's convention) so one with
24
+ * arguments, like `code -w`, works. There is no `vi` fallback: a script without a TTY must fail
25
+ * rather than hang. A missing editor, a spawn error, a non-zero exit, or a signal fails the
26
+ * command (`fallback` ends the no-editor message); the temp dir is removed before the failure exits. */
27
+ export function editInEditor(initial, fileName, fallback = "pass the new text or --file <path>") {
28
+ const editor = (process.env.VISUAL || process.env.EDITOR || "").trim();
29
+ if (editor === "")
30
+ fail(`no editor: set $VISUAL or $EDITOR, or ${fallback}`);
31
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-edit-"));
32
+ let saved;
33
+ let failure;
34
+ try {
35
+ const file = path.join(dir, fileName);
36
+ fs.writeFileSync(file, `${initial}\n`);
37
+ const r = spawnSync("sh", ["-c", `${editor} "$1"`, "sh", file], { stdio: "inherit" });
38
+ if (r.error || r.status !== 0) {
39
+ failure = `editor exited with ${exitReason(r)}; nothing changed`;
40
+ }
41
+ else {
42
+ saved = fs.readFileSync(file, "utf8");
43
+ }
44
+ }
45
+ finally {
46
+ fs.rmSync(dir, { recursive: true, force: true });
47
+ }
48
+ // fail() exits the process, which would skip the finally above, so it runs after cleanup.
49
+ if (failure !== undefined)
50
+ fail(failure);
51
+ return saved;
52
+ }
@@ -127,15 +127,22 @@ export const PAUSE_FOR_OVERFLOW_HINT = "for a longer or standing pause run bare
127
127
  * `pause --for` honors, the same not-initialized-masking rationale durationFlagSpec records).
128
128
  * missingValue names the command for a trailing `--in` (`wake --in needs a value`), the same
129
129
  * command-named pattern durationFlagSpec and sinceFlagSpec follow. */
130
- export const WAKE_IN_FLAG = {
131
- names: ["--in"],
132
- value: true,
133
- valueName: "<duration>",
134
- missingValue: "wake --in needs a value",
135
- validate: (value) => {
136
- failOverDurationCap("wake --in", parseDurationFlag("wake --in", value), PAUSE_FOR_MAX_MS);
137
- },
138
- };
130
+ function inFlagSpec(command) {
131
+ return {
132
+ names: ["--in"],
133
+ value: true,
134
+ valueName: "<duration>",
135
+ missingValue: `${command} --in needs a value`,
136
+ validate: (value) => {
137
+ failOverDurationCap(`${command} --in`, parseDurationFlag(`${command} --in`, value), PAUSE_FOR_MAX_MS);
138
+ },
139
+ };
140
+ }
141
+ export const WAKE_IN_FLAG = inFlagSpec("wake");
142
+ /** `tumwater stop --in <duration>`: the scheduled graceful stop, same shape and cap as wake's. */
143
+ export const STOP_IN_FLAG = inFlagSpec("stop");
144
+ /** `tumwater stop --cancel`: drop the scheduled stop. */
145
+ export const STOP_CANCEL_FLAG = { names: ["--cancel"] };
139
146
  /** The `--cancel` flag spec for `tumwater wake --cancel`: drop pending scheduled wakes. */
140
147
  export const WAKE_CANCEL_FLAG = { names: ["--cancel"] };
141
148
  /** `wake --list`: print the pending scheduled wakes instead of writing one. Takes no value. */
@@ -195,11 +202,17 @@ export const N_FLAG = {
195
202
  * siblings it needs neither a factory nor a missing-value error — so the constant is the
196
203
  * whole spec, and the render-vs-JSON switch stays in each command body beside its render. */
197
204
  export const JSON_FLAG = { names: ["--json"] };
205
+ /** The `--stat` flag spec, accepted by `diff --role`: print each patch as its `git diff --stat`
206
+ * summary. Value-less, like JSON_FLAG. */
207
+ export const STAT_FLAG = { names: ["--stat"] };
208
+ /** The `--summary` flag spec, accepted by `history`: tally each loop's tick outcomes instead of
209
+ * printing the rows. Value-less, like STAT_FLAG. */
210
+ export const SUMMARY_FLAG = { names: ["--summary"] };
198
211
  /** The `--force` flag spec, accepted by `retire` (the only override-the-rails command): one
199
212
  * definition of the flag's spelling beside ROLE_FLAG and JSON_FLAG, so the gate's accepted
200
213
  * vocabulary cannot drift from the command body's `args.includes("--force")`. */
201
214
  export const FORCE_FLAG = { names: ["--force"] };
202
- /** The `--dry-run` flag spec: preview a reclaim pass without deleting anything. Beside
215
+ /** The `--dry-run` flag spec: preview a reclaim pass or a retire without changing anything. Beside
203
216
  * FORCE_FLAG so its spelling lives with the other boolean flags the gate admits. */
204
217
  export const DRY_RUN_FLAG = { names: ["--dry-run"] };
205
218
  /** The `--last` flag spec for `tumwater tick <role> --last`: one definition of the flag's
@@ -20,6 +20,15 @@ function writeLine(text) {
20
20
  export function say(text) {
21
21
  writeLine(stripTerminalControls(text));
22
22
  }
23
+ /** Tell the operator, on stderr, that an operator backlog write landed in the working tree but
24
+ * was not committed to main (commitOperatorBacklogEdit returned false: the primary is off the
25
+ * base branch, or git failed). The write itself succeeded, so this warns instead of failing;
26
+ * a no-op when the commit landed. */
27
+ export function warnIfUncommitted(committed, file) {
28
+ if (committed)
29
+ return;
30
+ process.stderr.write(`warning: ${file} was written but not committed to main — commit it before the next fast-forward\n`);
31
+ }
23
32
  /** Print a value as pretty-printed (2-space) JSON — the `--json` output every CLI query
24
33
  * command (status, history, report, config) renders through: the pretty-print spelling
25
34
  * (indent 2) decided once so the machine-readable surface cannot drift per command. Writes
@@ -3,9 +3,9 @@
3
3
  * cmdAbort or config-commands's cmdConfig, and cli.ts keeps only routing and the help/version
4
4
  * surfaces. Each command owns its argument gate, ready-repo gating decision, and output shape,
5
5
  * with the wording and gating order moved verbatim from main()'s cases. */
6
- import { fail, say, sayJson, sayJsonOrRender } from "./cli-output.js";
6
+ import { fail, say, sayJson, sayJsonOrRender, warnIfUncommitted } from "./cli-output.js";
7
7
  import { flagValue, parseRoleFlag, rejectRepeatFlag } from "./cli-args.js";
8
- import { JSON_FLAG, rejectEqualsForm, rejectUnknownArgs, ROLE_FLAG } from "./cli-flag-specs.js";
8
+ import { JSON_FLAG, rejectEqualsForm, rejectUnknownArgs, ROLE_FLAG, STAT_FLAG } from "./cli-flag-specs.js";
9
9
  import { requireReadyRepo } from "./cli-repo-gate.js";
10
10
  import { defaultConfig, knownRoleIds, knownRoleIdsCached, loadConfigSafe } from "../config/config.js";
11
11
  import { gotSuffix, parsePositiveInt } from "../text/text.js";
@@ -19,6 +19,8 @@ import { collectFleetChanges, collectRoleChange } from "../change/change-data.js
19
19
  import { renderFleetChange, renderRoleChange } from "../change/change-render.js";
20
20
  import { readLandingQueue } from "../landing/landing-queue.js";
21
21
  import { renderLandingQueue } from "../landing/landing-queue-render.js";
22
+ import { orchestratorAlive } from "../fleet/orchestrator-info.js";
23
+ import { queueInFlight } from "../status/status-polls.js";
22
24
  import { rolePayload } from "../roles/role-view.js";
23
25
  import { renderRoleMarkdown } from "../roles/role-render.js";
24
26
  import { DIRECTOR_ROLE, unknownRoleMessage } from "../roles/roles.js";
@@ -26,7 +28,10 @@ import { saveRoleNote } from "../agent/role-notes-write.js";
26
28
  import { roleNotesPath } from "../paths.js";
27
29
  import { errorMessage } from "../text/text.js";
28
30
  import { readPromptFileArg, promptFileValue } from "./cli-command-args.js";
29
- import { answerQuestion, answerQuestionFromText, sayAnswered, sayQuestionList } from "./question-commands.js";
31
+ import { commitOperatorBacklogEdit } from "../backlog/backlog-commit.js";
32
+ import { stampedSubject } from "../git/commit-message.js";
33
+ import { answerEditorBuffer, answerQuestion, answerQuestionFromText, decisionFromEditorBuffer, questionListPayload, sayAnswered, sayQuestionList } from "./question-commands.js";
34
+ import { editInEditor } from "./cli-editor.js";
30
35
  /** `questions answer`'s `--file <path>` flag: the decision comes from the file, lines kept. */
31
36
  const FILE_SPEC = { names: ["--file"], value: true, valueName: "<path>" };
32
37
  /** `role`'s notebook-write flags: clear the note, or replace it from a file (`-` for stdin). */
@@ -87,17 +92,19 @@ export async function cmdDiff(root, args) {
87
92
  // command. Past the gate an absent worktree still degrades to a `no worktree for
88
93
  // <role>` line (exit 0), so the command answers in any initialized directory —
89
94
  // report's rationale.
90
- rejectUnknownArgs("diff", args, [ROLE_FLAG, JSON_FLAG]);
95
+ rejectUnknownArgs("diff", args, [ROLE_FLAG, JSON_FLAG, STAT_FLAG]);
91
96
  await requireReadyRepo(root);
92
97
  // Absent --role is the fleet-wide form: one line per loop holding pending work
93
98
  // (parseRoleFlag returns null only for an absent flag — an empty or unknown value
94
99
  // already failed above). A named role keeps the full per-role view.
95
100
  const role = parseRoleFlag(args, knownRoleIdsCached(root));
96
101
  if (role === null) {
102
+ if (args.includes("--stat"))
103
+ fail("diff --stat needs --role <id> (the fleet roster prints no patch)");
97
104
  sayJsonOrRender(args, await collectFleetChanges(root), renderFleetChange);
98
105
  }
99
106
  else {
100
- const change = await collectRoleChange(root, role);
107
+ const change = await collectRoleChange(root, role, { stat: args.includes("--stat") });
101
108
  sayJsonOrRender(args, change, renderRoleChange);
102
109
  }
103
110
  }
@@ -113,7 +120,11 @@ export async function cmdQueue(root, args) {
113
120
  // The JSON branch prints the reader's own document — raw sha and numeric enqueuedAt, not
114
121
  // the render's short sha and age — and the render is the collector's view with one clock
115
122
  // read, so `tumwater queue` and `tumwater status --json`'s landQueue cannot drift.
116
- sayJsonOrRender(args, readLandingQueue(root), (view) => renderLandingQueue(view, Date.now()));
123
+ // The in-flight marker rides along only while an orchestrator runs and the marker names a
124
+ // queued entry (queueInFlight) — status --json's landQueue.inFlight, so the two agree.
125
+ const view = readLandingQueue(root);
126
+ const inFlight = queueInFlight(root, view.entries, orchestratorAlive(root));
127
+ sayJsonOrRender(args, inFlight ? { ...view, inFlight } : view, (v) => renderLandingQueue(v, Date.now(), inFlight));
117
128
  }
118
129
  /** `tumwater backlog`: the planned/open entries as Markdown or as the GUI's JSON payload. */
119
130
  export async function cmdBacklog(root, args) {
@@ -195,15 +206,30 @@ export async function cmdQuestions(root, args) {
195
206
  if (words.length > 2)
196
207
  fail(`unexpected argument ${JSON.stringify(words[2])} — with --file the decision comes from the file`);
197
208
  const answered = answerQuestionFromText(root, n, readPromptFileArg(file));
209
+ warnIfUncommitted(await commitOperatorBacklogEdit(root, "QUESTIONS.md", stampedSubject("operator", `Answer question: ${answered.title}`)), "QUESTIONS.md");
198
210
  sayAnswered(n, answered.title, jsonFlag, answered.decision);
199
211
  return;
200
212
  }
201
213
  const decision = words.slice(2).join(" ").trim();
214
+ if (words.length === 2) {
215
+ const open = questionListPayload(root).questions;
216
+ const question = open[n - 1];
217
+ if (question === undefined)
218
+ fail(`no question at position ${n} (${open.length} open)`);
219
+ const edited = decisionFromEditorBuffer(editInEditor(answerEditorBuffer(question.title, question.body), `answer-${n}.md`, "pass a decision or --file <path>"));
220
+ if (edited.trim() === "")
221
+ fail("questions answer needs a decision: the editor left it blank; nothing changed");
222
+ const answered = answerQuestionFromText(root, n, edited, question.title);
223
+ warnIfUncommitted(await commitOperatorBacklogEdit(root, "QUESTIONS.md", stampedSubject("operator", `Answer question: ${answered.title}`)), "QUESTIONS.md");
224
+ sayAnswered(n, answered.title, jsonFlag, answered.decision);
225
+ return;
226
+ }
202
227
  if (decision === "")
203
228
  fail('questions answer needs a decision: questions answer <n> "<decision>"');
204
229
  // answerQuestion collapses the free-form decision to one line before writing it; report
205
230
  // that landed text in the --json payload so it matches the file, not the caller's spacing.
206
231
  const { title, decision: landedDecision } = answerQuestion(root, n, decision);
232
+ warnIfUncommitted(await commitOperatorBacklogEdit(root, "QUESTIONS.md", stampedSubject("operator", `Answer question: ${title}`)), "QUESTIONS.md");
207
233
  sayAnswered(n, title, jsonFlag, landedDecision);
208
234
  }
209
235
  /** `tumwater role <id>`: one loop's standing prompt and next tick's assembled prompt. */