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.
- package/README.md +26 -95
- package/dist/build-info.json +2 -2
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +41 -6
- package/dist/src/backlog/backlog-md.js +19 -1
- package/dist/src/backlog/backlog-write.js +152 -11
- package/dist/src/baseline/main-red.js +10 -4
- package/dist/src/brief-edit.js +42 -0
- package/dist/src/brief.js +33 -2
- package/dist/src/change/change-data.js +12 -3
- package/dist/src/cli/brief-command.js +56 -0
- package/dist/src/cli/cli-command-args.js +3 -4
- package/dist/src/cli/cli-editor.js +52 -0
- package/dist/src/cli/cli-flag-specs.js +23 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +32 -6
- package/dist/src/cli/cli-run.js +105 -12
- package/dist/src/cli/completion.js +149 -0
- package/dist/src/cli/help.js +71 -16
- package/dist/src/cli/question-commands.js +18 -2
- package/dist/src/cli.js +49 -10
- package/dist/src/config/config-views.js +12 -12
- package/dist/src/config/config-write.js +1 -1
- package/dist/src/doctor/doctor-backlog.js +75 -4
- package/dist/src/doctor/doctor-checks.js +31 -4
- package/dist/src/doctor/doctor-holds.js +83 -4
- package/dist/src/doctor/doctor-notify.js +28 -0
- package/dist/src/doctor/doctor.js +9 -2
- package/dist/src/events/event-format.js +12 -1
- package/dist/src/events/notify.js +75 -26
- package/dist/src/fleet/fleet-state.js +25 -1
- package/dist/src/fleet/orchestrator-info.js +2 -0
- package/dist/src/git/commit-message.js +6 -0
- package/dist/src/git/git.js +11 -0
- package/dist/src/gui/gui-endpoint-commands.js +52 -5
- package/dist/src/gui/gui-endpoints.js +12 -3
- package/dist/src/gui/gui-server.js +5 -2
- package/dist/src/history/history-data.js +21 -0
- package/dist/src/history/history.js +24 -4
- package/dist/src/inbox/inbox-attachments.js +9 -4
- package/dist/src/inbox/prompt-commands.js +61 -12
- package/dist/src/landing/landing-git.js +1 -2
- package/dist/src/landing/landing-queue-render.js +28 -3
- package/dist/src/landing/landing-slot.js +10 -0
- package/dist/src/loop/loop-pi.js +2 -2
- package/dist/src/loop/loop-state.js +9 -0
- package/dist/src/loop/loop-tick.js +9 -3
- package/dist/src/loop/loop.js +5 -1
- package/dist/src/operator/operator-commands.js +43 -4
- package/dist/src/operator/operator-intent.js +18 -1
- package/dist/src/operator/retire.js +17 -1
- package/dist/src/orchestrator/orchestrator-scheduling.js +60 -6
- package/dist/src/orchestrator/orchestrator.js +10 -5
- package/dist/src/paths.js +5 -0
- package/dist/src/pi/pi-watchdogs.js +44 -41
- package/dist/src/process/supervisor.js +15 -4
- package/dist/src/prompt/prompt-followup.js +7 -1
- package/dist/src/report/report-data.js +5 -1
- package/dist/src/report/report-render.js +46 -0
- package/dist/src/review/protected-edits.js +32 -0
- package/dist/src/review/review.js +28 -7
- package/dist/src/roles/role-catalog.js +17 -5
- package/dist/src/roles/roles.js +2 -1
- package/dist/src/scheduling/claims.js +10 -1
- package/dist/src/scheduling/scheduling.js +17 -1
- package/dist/src/status/status-data.js +10 -5
- package/dist/src/status/status-polls.js +25 -1
- package/dist/src/tick/stage-check.js +5 -1
- package/dist/src/tick/tick-detail-data.js +15 -2
- package/dist/src/tick/tick-detail.js +38 -10
- package/dist/src/tick/tick-stage.js +32 -2
- package/dist/src/tick/tick-verdict.js +1 -0
- package/dist/src/ui/badges.js +7 -0
- package/dist/src/ui/gui/gui-client-boot.js +10 -4
- package/dist/src/ui/gui/gui-client-fleet.js +9 -1
- package/dist/src/ui/gui/gui-client-history.js +2 -1
- package/dist/src/ui/gui/gui-client-loops.js +4 -5
- package/dist/src/ui/gui/gui-client-model.js +4 -1
- package/dist/src/ui/gui/gui-client-operator.js +61 -1
- package/dist/src/ui/gui/gui-client-pending.js +4 -3
- package/dist/src/ui/gui/gui-client-report.js +15 -5
- package/dist/src/ui/gui/gui-client-settings.js +27 -1
- package/dist/src/ui/gui/gui-page.js +1 -0
- package/dist/src/ui/status-model.js +24 -36
- package/dist/src/ui/status-payload.js +10 -1
- package/dist/src/ui/status-render.js +2 -1
- package/dist/src/ui/tone.js +3 -1
- package/dist/src/ui/tui/tui-frame.js +9 -1
- package/dist/src/ui/tui/tui-input.js +11 -0
- package/dist/src/ui/tui/tui-keys.js +129 -8
- package/dist/src/ui/tui/tui-pane.js +15 -1
- package/dist/src/ui/tui/tui.js +4 -2
- package/dist/src/verdict/fix-claim.js +98 -18
- package/dist/src/verdict/refusal.js +54 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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. */
|