omp-conductor 0.16.2 → 0.17.1

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 (52) hide show
  1. package/README.md +38 -4
  2. package/REFERENCE.md +18 -12
  3. package/package.json +2 -1
  4. package/schema/config.schema.json +16 -0
  5. package/src/admission.ts +159 -43
  6. package/src/availability.ts +27 -1
  7. package/src/briefs/worker.md +2 -0
  8. package/src/clack-ui.ts +83 -0
  9. package/src/command-manifest.ts +16 -7
  10. package/src/commands/arm.ts +11 -3
  11. package/src/commands/decision.ts +17 -7
  12. package/src/commands/doctor.ts +18 -1
  13. package/src/commands/hold.ts +9 -7
  14. package/src/commands/ledger.ts +25 -4
  15. package/src/commands/message.ts +32 -4
  16. package/src/commands/setup.ts +61 -10
  17. package/src/commands/stats.ts +9 -5
  18. package/src/commands/status.ts +32 -5
  19. package/src/commands/tail.ts +13 -1
  20. package/src/commands/watch.ts +16 -7
  21. package/src/config-schema.ts +20 -0
  22. package/src/config.ts +37 -0
  23. package/src/daemon.ts +1240 -18
  24. package/src/doctor.ts +310 -22
  25. package/src/escalate.ts +560 -57
  26. package/src/failure-class.ts +56 -13
  27. package/src/fleet.ts +224 -47
  28. package/src/gitops.ts +103 -24
  29. package/src/lifecycle.ts +7 -2
  30. package/src/orchestrator-tick.ts +372 -157
  31. package/src/privileged.ts +3 -0
  32. package/src/release-policy.ts +177 -5
  33. package/src/setup-answers.ts +135 -0
  34. package/src/setup-host.ts +193 -4
  35. package/src/setup-install.ts +2 -0
  36. package/src/setup-probe.ts +1 -0
  37. package/src/setup-wizard.ts +1296 -101
  38. package/src/setup.ts +60 -3
  39. package/src/status-render.ts +11 -1
  40. package/src/store.ts +333 -12
  41. package/src/tracker/github.ts +562 -13
  42. package/src/types.ts +204 -2
  43. package/src/ui/progress.ts +32 -0
  44. package/src/ui/style.ts +11 -0
  45. package/src/upgrade.ts +50 -19
  46. package/src/verbs/actions.ts +66 -18
  47. package/src/verbs/protocol.ts +45 -0
  48. package/src/verbs/server.ts +212 -11
  49. package/src/wizard-ui.ts +14 -5
  50. package/src/worker.ts +26 -0
  51. package/systemd/omp-conductor-recover.sh +73 -0
  52. package/systemd/recover-unit-test.sh +61 -0
@@ -0,0 +1,83 @@
1
+ // @clack/prompts is ESM-only and currently imports node:process in a shape
2
+ // that blocks a future `bun build`; this CLI intentionally executes TS in Bun.
3
+ import * as p from "@clack/prompts";
4
+ import { stdin, stdout } from "node:process";
5
+ import type { Readable, Writable } from "node:stream";
6
+
7
+ import type { TerminalUi } from "./wizard-ui.ts";
8
+
9
+ interface ClackIo {
10
+ input?: Readable;
11
+ output?: Writable;
12
+ }
13
+
14
+ /** Interactive WizardUi driver. Callers gate this on TTY input and output. */
15
+ export function clackUi(io: ClackIo = {}): TerminalUi {
16
+ const input = io.input ?? stdin;
17
+ const output = io.output ?? stdout;
18
+ const promptIo = { input, output };
19
+ let closed = false;
20
+ let started = false;
21
+ const start = (): void => {
22
+ if (started) return;
23
+ started = true;
24
+ p.intro("omp-conductor setup", { output });
25
+ };
26
+
27
+ return {
28
+ close: (message) => {
29
+ if (closed) return;
30
+ closed = true;
31
+ if (started) p.outro(message ?? "Setup finished.", { output });
32
+ },
33
+ notify: (message, type = "info") => {
34
+ start();
35
+ if (message.includes("\n")) {
36
+ const title = type === "warning" ? "Warning" : type === "error" ? "Error" : undefined;
37
+ p.note(message, title, { output });
38
+ return;
39
+ }
40
+ if (type === "warning") p.log.warning(message, { output });
41
+ else if (type === "error") p.log.error(message, { output });
42
+ else p.log.info(message, { output });
43
+ },
44
+ confirm: async (title, message) => {
45
+ start();
46
+ const result = await p.confirm({
47
+ ...promptIo,
48
+ message: `${title}\n${message}`,
49
+ initialValue: false,
50
+ });
51
+ return p.isCancel(result) ? undefined : result;
52
+ },
53
+ input: async (title, placeholder) => {
54
+ start();
55
+ const result = await p.text({
56
+ ...promptIo,
57
+ message: title,
58
+ placeholder,
59
+ defaultValue: placeholder,
60
+ });
61
+ return p.isCancel(result) ? undefined : result;
62
+ },
63
+ select: async (title, options, dialogOptions) => {
64
+ start();
65
+ const index = Math.max(
66
+ 0,
67
+ Math.min(dialogOptions?.initialIndex ?? 0, options.length - 1),
68
+ );
69
+ const initialValue = options[index]?.label;
70
+ const result = await p.select({
71
+ ...promptIo,
72
+ message: title,
73
+ options: options.map((option) => ({
74
+ value: option.label,
75
+ label: option.label,
76
+ hint: option.description,
77
+ })),
78
+ initialValue,
79
+ });
80
+ return p.isCancel(result) ? undefined : result;
81
+ },
82
+ };
83
+ }
@@ -59,7 +59,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
59
59
  description: "interview, configure the fleet, or install host and graph surfaces",
60
60
  scope: "project",
61
61
  usage: [
62
- "setup [area] [--no-ai] [--project NAME]",
62
+ "setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]",
63
63
  "setup host [NAME] [--project NAME]",
64
64
  "setup graph [--no-seed] [--print] [--project NAME]",
65
65
  ],
@@ -71,6 +71,8 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
71
71
  flags: [
72
72
  project(),
73
73
  toggle("--no-ai", "ask every question without AI repository probes"),
74
+ value("--answers", "answer every prompt from a JSON file"),
75
+ value("--save-answers", "save successful prompt answers as JSON"),
74
76
  toggle("--no-seed", "enable graph indexing without the initial seed"),
75
77
  toggle("--print", "print the graph install plan without changing anything"),
76
78
  ],
@@ -151,8 +153,8 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
151
153
  name: "status",
152
154
  description: "print layered fleet and deployment status",
153
155
  scope: "fleet",
154
- usage: ["status [--project NAME]"],
155
- flags: [project()],
156
+ usage: ["status [--project NAME] [--json]"],
157
+ flags: [project(), toggle("--json", "print the stable status JSON shape")],
156
158
  },
157
159
  {
158
160
  name: "stats",
@@ -188,8 +190,13 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
188
190
  name: "ledger",
189
191
  description: "print conductor verb and daemon decision history",
190
192
  scope: "project",
191
- usage: ["ledger [--issue N] [--limit N] [--project NAME]"],
192
- flags: [value("--issue", "filter by issue number"), value("--limit", "maximum rows"), project()],
193
+ usage: ["ledger [--issue N] [--limit N] [--project NAME] [--json]"],
194
+ flags: [
195
+ value("--issue", "filter by issue number"),
196
+ value("--limit", "maximum rows"),
197
+ project(),
198
+ toggle("--json", "print the stable ledger JSON shape"),
199
+ ],
193
200
  },
194
201
  {
195
202
  name: "hold",
@@ -362,7 +369,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
362
369
  "decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
363
370
  "decision resolve <id> --answer TEXT [--project NAME]",
364
371
  "decision withdraw <id> [--reason TEXT] [--project NAME]",
365
- "decision list [--project NAME]",
372
+ "decision list [--project NAME] [--json]",
366
373
  ],
367
374
  subcommands: [
368
375
  { name: "open", description: "record a question for the operator" },
@@ -376,6 +383,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
376
383
  value("--resolves-when", "automatic resolution condition"),
377
384
  value("--answer", "operator answer"),
378
385
  value("--reason", "withdrawal reason"),
386
+ toggle("--json", "print decision list as stable JSON"),
379
387
  project(),
380
388
  ],
381
389
  positionals: [{ name: "action" }, { name: "id" }],
@@ -386,7 +394,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
386
394
  scope: "project",
387
395
  usage: [
388
396
  "watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
389
- "watch list [--project NAME]",
397
+ "watch list [--project NAME] [--json]",
390
398
  ],
391
399
  subcommands: [
392
400
  { name: "add", description: "record a watch" },
@@ -396,6 +404,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
396
404
  value("--note", "note carried when the watch resolves"),
397
405
  value("--blocks", "what the watch blocks"),
398
406
  value("--resolves-when", "automatic resolution condition"),
407
+ toggle("--json", "print watch list as stable JSON"),
399
408
  project(),
400
409
  ],
401
410
  positionals: [{ name: "action" }],
@@ -8,13 +8,21 @@
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { armTicks } from "../fleet.ts";
11
+ import { withProgress } from "../ui/progress.ts";
11
12
 
12
13
  export async function armCommand(ctx: CommandContext): Promise<void> {
13
14
  for (const project of ctx.targetProjects()) {
14
- process.stdout.write("arm: sending inbound Telegram challenge…\n");
15
- const r = await armTicks(project.name);
15
+ // Proof-neutral wording: `claim-only` performs no Telegram send, so the
16
+ // progress line cannot promise a challenge that never goes out (#613). The
17
+ // result line names the proof that actually armed it.
18
+ const r = await withProgress(
19
+ "arm: verifying the arming proof…",
20
+ "Arming proof verified",
21
+ () => armTicks(project.name),
22
+ { plainMessage: true },
23
+ );
16
24
  process.stdout.write(
17
- `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
25
+ `ARMED — ${r.proof === "claim-only" ? "claim-only plumbing verdict proved" : `inbound round-trip proved with owner ${r.owner}`}; ticks are now live.\n` +
18
26
  `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
19
27
  );
20
28
  }
@@ -86,17 +86,27 @@ try {
86
86
  // were the whole reason an operator read the orchestrator's own reminder as
87
87
  // a question aimed at them (#459) — `watch list` shows those.
88
88
  const open = store.openDecisions(project.name).filter((d) => d.kind !== "watch");
89
- if (open.length === 0) {
89
+ const json = ctx.argv.includes("--json");
90
+ const now = Date.now();
91
+ const decisions = open.map((d) => ({
92
+ id: d.id,
93
+ ageHours: Math.max(0, Math.round((now - d.askedAt) / 3_600_000)),
94
+ blocks: d.blocks ?? null,
95
+ condition: d.condition === undefined ? null : d.conditionMetAt === undefined ? "pending" : "met",
96
+ question: d.question,
97
+ }));
98
+ if (json) {
99
+ process.stdout.write(`${JSON.stringify({ project: project.name, decisions }, null, 2)}\n`);
100
+ return;
101
+ }
102
+ if (decisions.length === 0) {
90
103
  process.stdout.write("no open decisions\n");
91
104
  return;
92
105
  }
93
- const now = Date.now();
94
- for (const d of open) {
95
- const condition =
96
- d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
97
- const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
106
+ for (const decision of decisions) {
98
107
  process.stdout.write(
99
- `${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
108
+ `${decision.id} ${decision.ageHours}h blocks:${decision.blocks ?? "-"} ` +
109
+ `condition:${decision.condition ?? "-"} ${decision.question}\n`,
100
110
  );
101
111
  }
102
112
  return;
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { CommandContext } from "./context.ts";
12
12
  import { runDoctor, type DoctorReport, type FindingStatus } from "../doctor.ts";
13
+ import { dim, fail, heading, ok, warn } from "../ui/style.ts";
13
14
 
14
15
  const DOCTOR_USAGE = `omp-conductor doctor — check the deployment faults that have already cost
15
16
  debugging sessions, mechanically and read-only.
@@ -69,6 +70,22 @@ export function renderDoctorReport(report: DoctorReport): string {
69
70
  return `${lines.join("\n")}\n`;
70
71
  }
71
72
 
73
+ function styleDoctorReport(report: DoctorReport, text: string): string {
74
+ return text
75
+ .split("\n")
76
+ .map((line, index) => {
77
+ if (index === 0) return heading(line);
78
+ if (index === 1)
79
+ return report.status === "fail" ? fail(line) : report.status === "warn" ? warn(line) : ok(line);
80
+ if (line.startsWith(" FAIL ")) return fail(line);
81
+ if (line.startsWith(" WARN ")) return warn(line);
82
+ if (line.startsWith(" PASS ")) return ok(line);
83
+ if (line.includes(" fix: ")) return dim(line);
84
+ return line;
85
+ })
86
+ .join("\n");
87
+ }
88
+
72
89
  export async function doctorCommand(ctx: CommandContext): Promise<void> {
73
90
  // Help first: parsing stops before any probe, config read, or side effect.
74
91
  if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
@@ -96,7 +113,7 @@ export async function doctorCommand(ctx: CommandContext): Promise<void> {
96
113
  probeTelegram: ctx.argv.includes("--probe-telegram"),
97
114
  });
98
115
  if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
99
- else process.stdout.write(renderDoctorReport(report));
116
+ else process.stdout.write(styleDoctorReport(report, renderDoctorReport(report)));
100
117
  // The one contract CI reads: nonzero iff something failed. Warnings pass.
101
118
  if (report.status === "fail") process.exitCode = 1;
102
119
  }
@@ -8,19 +8,21 @@
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { hold } from "../fleet.ts";
11
+ import { dim, ok } from "../ui/style.ts";
11
12
 
12
13
  export async function holdCommand(ctx: CommandContext): Promise<void> {
13
14
  const keepTicks = ctx.argv.includes("--keep-ticks");
14
15
  for (const project of ctx.targetProjects()) {
15
16
  const r = hold(project.name, "hold", keepTicks ? { keepTicks: true } : {});
16
17
  process.stdout.write(
17
- `held — claiming paused` +
18
- `${r.wasPaused ? " (already paused)" : ""}` +
19
- (r.disarmed === undefined
20
- ? "; ticks left armed — resume restores the fleet with no new arm challenge"
21
- : `; ticks disarmed at ${r.disarmed.path}` +
22
- `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}`) +
23
- `\ndaemon and pane left running; stop to stop the daemon too\n`,
18
+ ok(
19
+ `held claiming paused` +
20
+ `${r.wasPaused ? " (already paused)" : ""}` +
21
+ (r.disarmed === undefined
22
+ ? "; ticks left armed resume restores the fleet with no new arm challenge"
23
+ : `; ticks disarmed at ${r.disarmed.path}` +
24
+ `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}`),
25
+ ) + `\n${dim("daemon and pane left running; stop to stop the daemon too")}\n`,
24
26
  );
25
27
  }
26
28
  }
@@ -10,6 +10,7 @@ import type { CommandContext } from "./context.ts";
10
10
  import { findProject, loadConfig } from "../config.ts";
11
11
  import { dbPath, openStore } from "../store.ts";
12
12
  import { formatVerbLedgerEntry } from "../verbs/ledger.ts";
13
+ import { dim, heading } from "../ui/style.ts";
13
14
 
14
15
  export async function ledgerCommand(ctx: CommandContext): Promise<void> {
15
16
  const cfg = loadConfig();
@@ -35,10 +36,28 @@ try {
35
36
  ...(issue === undefined ? {} : { issue }),
36
37
  limit,
37
38
  });
39
+ if (ctx.argv.includes("--json")) {
40
+ process.stdout.write(
41
+ `${JSON.stringify(
42
+ {
43
+ project: p.name,
44
+ ...(issue === undefined ? {} : { issue }),
45
+ entries,
46
+ refused: entries.filter((entry) => entry.decision === "refused").length,
47
+ turnOverrides: overrides,
48
+ },
49
+ null,
50
+ 2,
51
+ )}\n`,
52
+ );
53
+ return;
54
+ }
38
55
  if (entries.length === 0 && overrides.length === 0) {
39
56
  process.stdout.write(
40
- `no conductor actions recorded for ${p.name}` +
41
- `${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
57
+ dim(
58
+ `no conductor actions recorded for ${p.name}` +
59
+ `${issue === undefined ? "" : ` on #${String(issue)}`}`,
60
+ ) + "\n",
42
61
  );
43
62
  return;
44
63
  }
@@ -46,13 +65,15 @@ try {
46
65
  if (entries.length > 0) {
47
66
  const refused = entries.filter((e) => e.decision === "refused").length;
48
67
  blocks.push(
49
- `${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
68
+ heading(`${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)`) +
69
+ "\n" +
50
70
  entries.flatMap(formatVerbLedgerEntry).join("\n"),
51
71
  );
52
72
  }
53
73
  if (overrides.length > 0) {
54
74
  blocks.push(
55
- `${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
75
+ heading(`${p.name} — ${overrides.length} turn override(s) (newest first)`) +
76
+ "\n" +
56
77
  overrides
57
78
  .map(
58
79
  (entry) =>
@@ -15,10 +15,11 @@
15
15
 
16
16
  import type { CommandContext } from "./context.ts";
17
17
  import { randomUUID } from "node:crypto";
18
+ import { availabilityState, formatNextWindowOpening } from "../availability.ts";
18
19
  import { findProject, loadConfig } from "../config.ts";
19
20
  import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
20
21
  import { dbPath, openStore } from "../store.ts";
21
- import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
22
+ import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory, type ProjectConfig } from "../types.ts";
22
23
  import { validateQuestionShape } from "../ask.ts";
23
24
 
24
25
  /** The floor's "this needs an answer" marker, as every other classifier reads it. */
@@ -96,8 +97,35 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
96
97
  ? // Not "into the topic": a stale topic degrades to the flat chat with
97
98
  // its own warning on stderr, and this line must not contradict it.
98
99
  `message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
99
- : `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
100
- `${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
101
- "nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
100
+ : heldLine(project, outcome),
101
+ );
102
+ }
103
+
104
+ /** The held-notice line names the future that releases it, in words an
105
+ * operator can act on: the working-hours window opening, or the daily digest.
106
+ * The earlier spelling — "the next digest or working-hours catch-up" — read
107
+ * the same for both and hid exactly the 23-hour hold that #596 is about. */
108
+ function heldLine(
109
+ project: ProjectConfig,
110
+ outcome: Extract<OperatorMessageOutcome, { kind: "held" }>,
111
+ ): string {
112
+ if (outcome.reason === "availability") {
113
+ const state = availabilityState(project.reporting, Date.now());
114
+ const opening =
115
+ state.mode === "quiet" && state.nextTransitionAt !== undefined && state.timezone !== undefined
116
+ ? ` at ${formatNextWindowOpening(state.nextTransitionAt, state.timezone)}`
117
+ : "";
118
+ return (
119
+ `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
120
+ `held until the working-hours window opens${opening})\n` +
121
+ "nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
122
+ );
123
+ }
124
+ const digestAt = project.reporting?.digest.at;
125
+ const when = digestAt === undefined ? "the next digest" : `the ${digestAt} digest`;
126
+ return (
127
+ `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
128
+ `digest-only — held until ${when})\n` +
129
+ `nothing was sent; the daemon releases it with ${when}\n`
102
130
  );
103
131
  }
@@ -17,7 +17,16 @@ import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
17
17
  import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
18
18
  import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
19
19
  import { telegramStateDir } from "../fleet.ts";
20
+ import {
21
+ answersUi,
22
+ guardNonInteractiveUi,
23
+ loadAnswersFile,
24
+ recordingAnswersUi,
25
+ saveAnswersFile,
26
+ type RecordedAnswersUi,
27
+ } from "../setup-answers.ts";
20
28
  import { terminalUi, type WizardUi } from "../wizard-ui.ts";
29
+ import { interactiveUi } from "../ui/progress.ts";
21
30
  import type { ConductorConfig, ProjectConfig } from "../types.ts";
22
31
 
23
32
  /**
@@ -30,7 +39,7 @@ const SETUP_USAGE = `omp-conductor setup — interview, then write config.json,
30
39
  and the staged host files behind one confirm.
31
40
 
32
41
  usage:
33
- omp-conductor setup [area] [--no-ai] [--project NAME]
42
+ omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]
34
43
  omp-conductor setup host [NAME] [--project NAME]
35
44
  omp-conductor setup graph [--no-seed] [--print] [--project NAME]
36
45
 
@@ -44,9 +53,11 @@ amend areas:
44
53
  ${AMEND_AREA_IDS.join(", ")}
45
54
 
46
55
  flags:
47
- --project NAME the project to configure or amend (or NAME positionally
48
- for \`setup host\`)
49
- --no-ai ask every question, propose nothing (no AI repo reads)`;
56
+ --project NAME the project to configure or amend (or NAME positionally
57
+ for \`setup host\`)
58
+ --no-ai ask every question, propose nothing (no AI repo reads)
59
+ --answers FILE answer every prompt from JSON; never opens a prompt
60
+ --save-answers FILE save successful prompt answers as replayable JSON`;
50
61
 
51
62
  /**
52
63
  * Resolve the project an install subcommand (`setup host`, `setup graph`)
@@ -97,6 +108,7 @@ async function offerCompletionInstall(ui: WizardUi): Promise<void> {
97
108
  const install = await ui.confirm(
98
109
  "Install shell completions?",
99
110
  "Writes a generated completion script and one idempotent source line to your shell rc file.",
111
+ { key: "install-shell-completions" },
100
112
  );
101
113
  if (install !== true) return;
102
114
 
@@ -107,13 +119,26 @@ async function offerCompletionInstall(ui: WizardUi): Promise<void> {
107
119
  { label: "zsh", description: "Install through ~/.zshrc" },
108
120
  { label: "bash", description: "Install through ~/.bashrc" },
109
121
  ],
110
- { initialIndex: detected === "bash" ? 1 : 0 },
122
+ { key: "completion-shell", initialIndex: detected === "bash" ? 1 : 0 },
111
123
  );
112
124
  if (selected !== "zsh" && selected !== "bash") return;
113
125
  const installed = await installShellCompletions(selected);
114
126
  ui.notify(`Installed completions at ${installed.scriptPath}; sourced from ${installed.rcPath}.`);
115
127
  }
116
128
 
129
+ function setupValueFlag(argv: readonly string[], name: "--answers" | "--save-answers"): string | undefined {
130
+ const inline = argv.find((arg) => arg.startsWith(`${name}=`));
131
+ const at = argv.indexOf(name);
132
+ if (inline !== undefined && at !== -1) throw new Error(`${name} may be passed only once`);
133
+ const value = inline?.slice(name.length + 1) ?? (at === -1 ? undefined : argv[at + 1]);
134
+ if (value === undefined) {
135
+ if (at !== -1) throw new Error(`${name} requires a file path`);
136
+ return undefined;
137
+ }
138
+ if (value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a file path`);
139
+ return value;
140
+ }
141
+
117
142
  export async function setupCommand(ctx: CommandContext): Promise<void> {
118
143
  // Help first, and only in the first trailing position: a help request
119
144
  // must never open a UI, read config, probe GitHub or pause dispatch.
@@ -125,10 +150,22 @@ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
125
150
  }
126
151
  const sub = ctx.argv[1];
127
152
  const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
128
- // The terminal surface owns stdin for whichever path runs, and is released
129
- // in a finally: a throw with readline still attached leaves the operator's
130
- // shell without an echo.
131
- const ui = terminalUi();
153
+ // Clack owns an interactive terminal. Pipes retain the byte-pinned readline
154
+ // protocol, while --answers installs a prompt-free driver.
155
+ const answersPath = setupValueFlag(ctx.argv, "--answers");
156
+ const saveAnswersPath = setupValueFlag(ctx.argv, "--save-answers");
157
+ const useClack = answersPath === undefined && interactiveUi();
158
+ const baseUi = answersPath
159
+ ? answersUi(loadAnswersFile(answersPath))
160
+ : useClack
161
+ ? (await import("../clack-ui.ts")).clackUi()
162
+ : terminalUi();
163
+ const guardedUi = answersPath === undefined && !process.stdin.isTTY ? guardNonInteractiveUi(baseUi) : baseUi;
164
+ const recording: RecordedAnswersUi | undefined =
165
+ saveAnswersPath === undefined ? undefined : recordingAnswersUi(guardedUi);
166
+ const ui = recording?.ui ?? guardedUi;
167
+ let saveCompletedAnswers = false;
168
+ let closeMessage = "Setup failed.";
132
169
  try {
133
170
  // `host` and `graph` are install subcommands, checked BEFORE the amend
134
171
  // areas. They are not areas — routing them through the area validation
@@ -170,6 +207,8 @@ try {
170
207
  // `staged` is a success on a host that has no systemd: the files are
171
208
  // real, only the enable step is impossible.
172
209
  if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
210
+ saveCompletedAnswers = outcome.kind !== "declined";
211
+ closeMessage = outcome.kind === "declined" ? "Setup incomplete." : "Setup complete.";
173
212
  return;
174
213
  }
175
214
  let area: AmendAreaId | undefined;
@@ -203,7 +242,19 @@ try {
203
242
  );
204
243
  }
205
244
  }
245
+ saveCompletedAnswers = completed;
246
+ closeMessage = completed ? "Setup complete." : "Setup incomplete.";
206
247
  } finally {
207
- ui.close();
248
+ try {
249
+ if (saveCompletedAnswers && saveAnswersPath !== undefined && recording !== undefined) {
250
+ saveAnswersFile(saveAnswersPath, recording.answers);
251
+ ui.notify(`Saved setup answers to ${saveAnswersPath}.`);
252
+ }
253
+ } catch (err) {
254
+ closeMessage = "Setup failed.";
255
+ throw err;
256
+ } finally {
257
+ ui.close(closeMessage);
258
+ }
208
259
  }
209
260
  }
@@ -13,6 +13,7 @@ import type { CommandContext } from "./context.ts";
13
13
  import { findProject, loadConfig } from "../config.ts";
14
14
  import { dbPath, openStore, utcDay } from "../store.ts";
15
15
  import { computeStats, renderStatsHuman, type StatsWindow } from "../stats.ts";
16
+ import { heading } from "../ui/style.ts";
16
17
 
17
18
  const DAY_MS = 24 * 60 * 60 * 1_000;
18
19
  const DATE_FORM = /^\d{4}-\d{2}-\d{2}$/;
@@ -120,11 +121,14 @@ export async function statsCommand(ctx: CommandContext): Promise<void> {
120
121
  ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
121
122
  runs: store.statsRuns(project.name, window.sinceEpochMs),
122
123
  });
123
- process.stdout.write(
124
- ctx.argv.includes("--json")
125
- ? `${JSON.stringify(report, null, 2)}\n`
126
- : renderStatsHuman(report),
127
- );
124
+ if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
125
+ else {
126
+ const human = renderStatsHuman(report);
127
+ const newline = human.indexOf("\n");
128
+ process.stdout.write(
129
+ newline < 0 ? heading(human) : `${heading(human.slice(0, newline))}${human.slice(newline)}`,
130
+ );
131
+ }
128
132
  } finally {
129
133
  store.close();
130
134
  }
@@ -10,8 +10,9 @@ import type { CommandContext } from "./context.ts";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { stateDir } from "../config.ts";
13
- import { renderStatus } from "../fleet.ts";
13
+ import { collectFleetStatus, renderFleetStatusReport } from "../fleet.ts";
14
14
  import { STALL_MARKER_FILE } from "../orchestrator-tick.ts";
15
+ import { fail, heading, ok, warn } from "../ui/style.ts";
15
16
 
16
17
  /**
17
18
  * The orchestrator half, and the one thing `status` has ever known about the
@@ -40,9 +41,35 @@ function stallLine(): string | undefined {
40
41
  return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
41
42
  }
42
43
 
44
+ function styleStatus(text: string): string {
45
+ return text
46
+ .split("\n")
47
+ .map((line) => {
48
+ if (
49
+ line === "caps" ||
50
+ line === "daemon" ||
51
+ line === "active runs" ||
52
+ line === "active runs (none)" ||
53
+ line.startsWith("project ")
54
+ )
55
+ return heading(line);
56
+ if (line.includes("STALLED")) return fail(line);
57
+ if (/^(dispatch|ticks|pane|herdr|telegram| healthz)\s+.*\b(running|healthy|ok)\b/.test(line))
58
+ return ok(line);
59
+ if (/^(dispatch|ticks|daemon)\s+.*\b(paused|stopped|not running|overdue)\b/.test(line))
60
+ return warn(line);
61
+ return line;
62
+ })
63
+ .join("\n");
64
+ }
65
+
43
66
  export async function statusCommand(ctx: CommandContext): Promise<void> {
44
- const project = ctx.projectFlag;
45
- const text = await renderStatus(project);
46
- const stalled = stallLine();
47
- process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
67
+ const report = await collectFleetStatus(ctx.projectFlag);
68
+ const stalled = stallLine();
69
+ if (ctx.argv.includes("--json")) {
70
+ process.stdout.write(`${JSON.stringify({ ...report, orchestratorStall: stalled }, null, 2)}\n`);
71
+ return;
72
+ }
73
+ const text = styleStatus(renderFleetStatusReport(report));
74
+ process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${fail(stalled)}\n`}`);
48
75
  }
@@ -13,6 +13,7 @@ import { findProject, loadConfig } from "../config.ts";
13
13
  import { dbPath, LIVE_STATES, openStore } from "../store.ts";
14
14
  import { formatTranscriptLine, prop } from "../transcript.ts";
15
15
  import type { Store } from "../types.ts";
16
+ import { dim, fail, ok, warn } from "../ui/style.ts";
16
17
 
17
18
  /** How often `tail` re-stats the transcripts it is following. */
18
19
  const TAIL_POLL_MS = 1_000;
@@ -142,6 +143,16 @@ export function renderAdvisorLine(line: string): string | undefined {
142
143
  return out.length === 0 ? undefined : `[advisor] ${out.join("\n")}`;
143
144
  }
144
145
 
146
+ function styleTailLine(line: string): string {
147
+ if (line.startsWith("[advisor] blocker:")) return fail(line);
148
+ if (line.startsWith("[advisor] warning:")) return warn(line);
149
+ if (line.startsWith("[advisor] nit:")) return dim(line);
150
+ if (line === "run ended: merged" || line === "run ended: pushed-green") return ok(line);
151
+ if (/^run ended: (?:failed|killed|orphaned)$/.test(line)) return fail(line);
152
+ if (line.startsWith("run ended:")) return warn(line);
153
+ return line;
154
+ }
155
+
145
156
  /**
146
157
  * Follow one run's transcripts the way `tail -f` follows a log: the worker's
147
158
  * own transcript plus any `__advisor*.jsonl` sitting beside it (the mid-run
@@ -173,7 +184,8 @@ export async function tailRun(
173
184
  } = {},
174
185
  ): Promise<void> {
175
186
  const store = deps.store ?? openStore(dbPath());
176
- const write = deps.write ?? ((line: string) => process.stdout.write(`${line}\n`));
187
+ const write =
188
+ deps.write ?? ((line: string) => process.stdout.write(`${styleTailLine(line)}\n`));
177
189
  const pollMs = deps.pollMs ?? TAIL_POLL_MS;
178
190
  const quietMs = deps.quietMs ?? TAIL_QUIET_MS;
179
191
  try {