omp-conductor 0.16.2 → 0.17.0

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.
@@ -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 {
@@ -50,18 +50,27 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
50
50
  }
51
51
 
52
52
  if (sub === "list" || sub === undefined) {
53
- const watches = store.openDecisions(project.name).filter((d) => d.kind === "watch");
53
+ const open = store.openDecisions(project.name).filter((d) => d.kind === "watch");
54
+ const now = Date.now();
55
+ const watches = open.map((d) => ({
56
+ id: d.id,
57
+ ageHours: Math.max(0, Math.round((now - d.askedAt) / 3_600_000)),
58
+ blocks: d.blocks ?? null,
59
+ condition: d.condition === undefined ? null : d.conditionMetAt === undefined ? "pending" : "met",
60
+ note: d.question,
61
+ }));
62
+ if (ctx.argv.includes("--json")) {
63
+ process.stdout.write(`${JSON.stringify({ project: project.name, watches }, null, 2)}\n`);
64
+ return;
65
+ }
54
66
  if (watches.length === 0) {
55
67
  process.stdout.write("no watches\n");
56
68
  return;
57
69
  }
58
- const now = Date.now();
59
- for (const d of watches) {
60
- const condition =
61
- d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
62
- const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
70
+ for (const watch of watches) {
63
71
  process.stdout.write(
64
- `${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
72
+ `${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
73
+ `condition:${watch.condition ?? "-"} ${watch.note}\n`,
65
74
  );
66
75
  }
67
76
  return;
package/src/fleet.ts CHANGED
@@ -37,7 +37,7 @@ import { dbPath, openStore } from "./store.ts";
37
37
  import { renderBriefForProject } from "./setup.ts";
38
38
  import type { DaemonStop, ProjectConfig, Store } from "./types.ts";
39
39
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
40
- import { isPaused, setPaused, statusSnapshot } from "./daemon.ts";
40
+ import { isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
41
41
  import {
42
42
  healthCheck,
43
43
  isAlive,
@@ -1311,8 +1311,24 @@ function servedProjectName(payload: object): string | undefined {
1311
1311
  return undefined;
1312
1312
  }
1313
1313
 
1314
- export async function renderStatus(projectName?: string): Promise<string> {
1315
- const s = statusSnapshot(projectName);
1314
+ export type FleetStatusReport = StatusSnapshot & {
1315
+ observedAt: number;
1316
+ layers: FleetLayers;
1317
+ daemon: FleetDaemonProbe | undefined;
1318
+ telegram: TelegramHealth;
1319
+ codeGraph: CodeGraphHealth;
1320
+ brief: string | undefined;
1321
+ decisions: string | undefined;
1322
+ failureClasses: string | undefined;
1323
+ workerPhases: { issue: number; phase: WorkerPausePhase }[];
1324
+ intake: string | undefined;
1325
+ lastStop: DaemonStop | undefined;
1326
+ siblings: { project: string; live: number }[];
1327
+ };
1328
+
1329
+ /** Collects the complete status payload once for both text and JSON renderers. */
1330
+ export async function collectFleetStatus(projectName?: string): Promise<FleetStatusReport> {
1331
+ const snapshot = statusSnapshot(projectName);
1316
1332
  const layers = fleetLayers(projectName);
1317
1333
  const project = findProject(loadConfig(), projectName);
1318
1334
  const rec = livingDaemon();
@@ -1325,7 +1341,7 @@ export async function renderStatus(projectName?: string): Promise<string> {
1325
1341
  // Read here rather than in `statusSnapshot`, which is synchronous and used
1326
1342
  // by callers that must not shell out. An unmetered project never spawns
1327
1343
  // the provider at all.
1328
- readPlanUsage(s.caps.planUsage, sharedUsageSource()),
1344
+ readPlanUsage(snapshot.caps.planUsage, sharedUsageSource()),
1329
1345
  // Same reasoning as `planUsage`: the snapshot is synchronous, this read is
1330
1346
  // a shell-out, and undefined on any failure — one missing row, never a
1331
1347
  // broken report (#188).
@@ -1347,7 +1363,9 @@ export async function renderStatus(projectName?: string): Promise<string> {
1347
1363
  const healthBody = projectHealth.kind === "ok" ? rawHealth?.body : undefined;
1348
1364
  const cached = codeGraphFromHealthz(healthBody, project.name);
1349
1365
  const codeGraph = cached ?? (await probeCodeGraph(project));
1350
- const workerPhases = workerPhasesFromHealthz(healthBody, project.name);
1366
+ const workerPhases = [...workerPhasesFromHealthz(healthBody, project.name)].map(
1367
+ ([issue, phase]) => ({ issue, phase }),
1368
+ );
1351
1369
  // The newest host-wide stop/restart provenance (#378). Read here — not in
1352
1370
  // `statusSnapshot`, which is synchronous and belongs to the daemon module —
1353
1371
  // and rendered identically from either project: the daemon_stops table is
@@ -1359,32 +1377,54 @@ export async function renderStatus(projectName?: string): Promise<string> {
1359
1377
  try {
1360
1378
  lastStop = store.latestDaemonStop();
1361
1379
  // Shared-daemon visibility (#545): every configured project other than the
1362
- // one being viewed, with its live-run count. Read from the same open store
1363
- // as the provenance above, so a status reader can tell "my fleet is idle"
1364
- // from "the process I am about to stop is busy".
1380
+ // one being viewed, with its live-run count.
1365
1381
  siblings = loadConfig()
1366
1382
  .projects.filter((p) => p.name !== project.name)
1367
1383
  .map((p) => ({ project: p.name, live: store.liveRuns(p.name).length }));
1368
1384
  } finally {
1369
1385
  store.close();
1370
1386
  }
1371
- return formatFleetStatus(
1372
- { ...s, planUsage, github },
1387
+ return {
1388
+ ...snapshot,
1389
+ planUsage,
1390
+ github,
1391
+ observedAt: Date.now(),
1373
1392
  layers,
1374
1393
  daemon,
1375
1394
  telegram,
1376
- Date.now(),
1377
1395
  codeGraph,
1378
- briefStatusLine(project),
1379
- decisionStatusLine(project.name),
1380
- failureClassBlock(project.name),
1396
+ brief: briefStatusLine(project),
1397
+ decisions: decisionStatusLine(project.name),
1398
+ failureClasses: failureClassBlock(project.name),
1381
1399
  workerPhases,
1382
- intakeStatusLine(project.name),
1400
+ intake: intakeStatusLine(project.name),
1383
1401
  lastStop,
1384
1402
  siblings,
1403
+ };
1404
+ }
1405
+
1406
+ export function renderFleetStatusReport(report: FleetStatusReport): string {
1407
+ return formatFleetStatus(
1408
+ report,
1409
+ report.layers,
1410
+ report.daemon,
1411
+ report.telegram,
1412
+ report.observedAt,
1413
+ report.codeGraph,
1414
+ report.brief,
1415
+ report.decisions,
1416
+ report.failureClasses,
1417
+ new Map(report.workerPhases.map(({ issue, phase }) => [issue, phase])),
1418
+ report.intake,
1419
+ report.lastStop,
1420
+ report.siblings,
1385
1421
  );
1386
1422
  }
1387
1423
 
1424
+ export async function renderStatus(projectName?: string): Promise<string> {
1425
+ return renderFleetStatusReport(await collectFleetStatus(projectName));
1426
+ }
1427
+
1388
1428
  /**
1389
1429
  * One line naming the brief layout, or nothing when it cannot be read.
1390
1430
  *
@@ -103,7 +103,7 @@ import {
103
103
  type AskResult,
104
104
  } from "./ask.ts";
105
105
  import { deliverOperatorMessage } from "./reports.ts";
106
- import type { RunRecord } from "./types.ts";
106
+ import type { RecoveryAction, RunRecord } from "./types.ts";
107
107
  import { dbPath, openStore } from "./store.ts";
108
108
  import { digestDue, localDayKey } from "./digest-schedule.ts";
109
109
  import { heldNoticeId } from "./notices.ts";
@@ -471,6 +471,34 @@ export function defaultTickMessage(
471
471
  * `REPORT_SCOPES` fails to compile here instead of resolving to `undefined` at
472
472
  * the point of use.
473
473
  */
474
+ /**
475
+ * Which recovery actions resolve a run without leaving human work behind.
476
+ * Exhaustive over {@link RecoveryAction} so adding an action to the vocabulary
477
+ * forces a decision here: a recovered row carrying `true` is genuinely done and
478
+ * may be summarised as "already handled"; `false` — `escalate`, `hold`, `none` —
479
+ * still needs the orchestrator's Duty 1 attention, so it must never inherit the
480
+ * suppressive "do not re-triage" sentence (#610).
481
+ */
482
+ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
483
+ requeue: true,
484
+ continue: true,
485
+ "rerun-checks": true,
486
+ settle: true,
487
+ escalate: false,
488
+ hold: false,
489
+ none: false,
490
+ };
491
+
492
+ /** Bounded name-and-count summary shared by the two recovered-row groups. */
493
+ function recoveredSummary(group: readonly RunRecord[]): string {
494
+ const named = group
495
+ .slice(0, 5)
496
+ .map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
497
+ .join(", ");
498
+ const rest = group.length > 5 ? `, +${group.length - 5} more` : "";
499
+ return `${group.length} (${named}${rest})`;
500
+ }
501
+
474
502
  /**
475
503
  * One line naming what the daemon recovered without asking (#132).
476
504
  *
@@ -480,12 +508,23 @@ export function defaultTickMessage(
480
508
  */
481
509
  export function recoveryDigestLine(recovered: readonly RunRecord[]): string | undefined {
482
510
  if (recovered.length === 0) return undefined;
483
- const named = recovered
484
- .slice(0, 5)
485
- .map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
486
- .join(", ");
487
- const rest = recovered.length > 5 ? `, +${recovered.length - 5} more` : "";
488
- return `Auto-recovered since last tick: ${recovered.length} (${named}${rest}) — already handled, do not re-triage these.`;
511
+ const handled: RunRecord[] = [];
512
+ const triage: RunRecord[] = [];
513
+ for (const r of recovered) {
514
+ if (r.recoveryAction !== undefined && AUTONOMOUS_RECOVERY_ACTIONS[r.recoveryAction]) {
515
+ handled.push(r);
516
+ } else {
517
+ triage.push(r);
518
+ }
519
+ }
520
+ const lines: string[] = [];
521
+ if (handled.length > 0) {
522
+ lines.push(`Auto-recovered since last tick: ${recoveredSummary(handled)} — already handled, do not re-triage these.`);
523
+ }
524
+ if (triage.length > 0) {
525
+ lines.push(`Recovered but needs Duty 1 triage: ${recoveredSummary(triage)} — inspect these runs.`);
526
+ }
527
+ return lines.join("\n");
489
528
  }
490
529
 
491
530
  /**
package/src/privileged.ts CHANGED
@@ -88,6 +88,8 @@ export interface RunPrivilegedOptions {
88
88
  deps?: PrivilegedDeps;
89
89
  /** Confirm title. Defaults to a generic one; callers name their verb. */
90
90
  title?: string;
91
+ /** Stable answer-file key for this batch's confirmation. */
92
+ answerKey?: string;
91
93
  /** Extra lines shown above the step list — what this batch is for. */
92
94
  preamble?: readonly string[];
93
95
  /**
@@ -201,6 +203,7 @@ export async function runPrivileged(
201
203
  const go = await ui.confirm(
202
204
  options.title ?? "Run these steps now?",
203
205
  `${steps.length} step(s), in the order shown. Anything that fails stops the rest and prints what is left.`,
206
+ { key: options.answerKey ?? "run-privileged-steps" },
204
207
  );
205
208
  // `undefined` is a dismissal rather than a "no", but for a batch that has not
206
209
  // started they mean the same thing: run nothing.
@@ -0,0 +1,135 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+
4
+ import type { PromptOptions, SelectOptions, TerminalUi, WizardUi } from "./wizard-ui.ts";
5
+
6
+ const answerFileSchema = z.record(z.string(), z.union([z.string(), z.boolean()]));
7
+
8
+ export type SetupAnswerValues = Record<string, string | boolean>;
9
+
10
+ function promptName(title: string, options: PromptOptions): string {
11
+ return `answer "${options.key}" for "${title}"`;
12
+ }
13
+
14
+ function requiredAnswer(answers: SetupAnswerValues, title: string, options: PromptOptions): string | boolean {
15
+ if (!Object.hasOwn(answers, options.key)) {
16
+ throw new Error(`missing ${promptName(title, options)}; add it to the --answers file`);
17
+ }
18
+ return answers[options.key]!;
19
+ }
20
+
21
+ /** A prompt-free WizardUi backed only by validated answers. */
22
+ export function answersUi(
23
+ answers: SetupAnswerValues,
24
+ notify: WizardUi["notify"] = (message) => process.stdout.write(`${message}\n`),
25
+ ): TerminalUi {
26
+ return {
27
+ notify,
28
+ close: () => {},
29
+ async confirm(title, _message, options) {
30
+ const answer = requiredAnswer(answers, title, options);
31
+ if (typeof answer !== "boolean") {
32
+ throw new Error(`${promptName(title, options)} must be a boolean`);
33
+ }
34
+ return answer;
35
+ },
36
+ async input(title, _placeholder, options) {
37
+ const answer = requiredAnswer(answers, title, options);
38
+ if (typeof answer !== "string") {
39
+ throw new Error(`${promptName(title, options)} must be a string`);
40
+ }
41
+ return answer;
42
+ },
43
+ async select(title, choices, options) {
44
+ const answer = requiredAnswer(answers, title, options);
45
+ if (typeof answer !== "string") {
46
+ throw new Error(`${promptName(title, options)} must be a listed label`);
47
+ }
48
+ if (!choices.some((choice) => choice.label === answer)) {
49
+ throw new Error(`${promptName(title, options)} must be a listed label`);
50
+ }
51
+ return answer;
52
+ },
53
+ };
54
+ }
55
+
56
+ export function loadAnswersFile(path: string): SetupAnswerValues {
57
+ let raw: string;
58
+ try {
59
+ raw = readFileSync(path, "utf8");
60
+ } catch (err) {
61
+ throw new Error(`could not read answers file "${path}": ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+
64
+ let decoded: unknown;
65
+ try {
66
+ decoded = JSON.parse(raw);
67
+ } catch (err) {
68
+ throw new Error(`answers file "${path}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ const parsed = answerFileSchema.safeParse(decoded);
71
+ if (!parsed.success) {
72
+ throw new Error(`answers file "${path}" must be a JSON object whose values are strings or booleans`);
73
+ }
74
+ return parsed.data;
75
+ }
76
+
77
+ export interface RecordedAnswersUi {
78
+ ui: TerminalUi;
79
+ answers: SetupAnswerValues;
80
+ }
81
+
82
+ type ClosableWizardUi = WizardUi & Partial<Pick<TerminalUi, "close">>;
83
+
84
+ /** Decorates any driver and records only answers that were actually returned. */
85
+ export function recordingAnswersUi(inner: ClosableWizardUi): RecordedAnswersUi {
86
+ const answers: SetupAnswerValues = {};
87
+ return {
88
+ answers,
89
+ ui: {
90
+ notify: (message, type) => inner.notify(message, type),
91
+ close: (message) => inner.close?.(message),
92
+ async confirm(title, message, options) {
93
+ const answer = await inner.confirm(title, message, options);
94
+ if (answer !== undefined) answers[options.key] = answer;
95
+ return answer;
96
+ },
97
+ async input(title, placeholder, options) {
98
+ const answer = await inner.input(title, placeholder, options);
99
+ if (answer !== undefined) answers[options.key] = answer;
100
+ return answer;
101
+ },
102
+ async select(title, choices, options) {
103
+ const answer = await inner.select(title, choices, options);
104
+ if (answer !== undefined) answers[options.key] = answer;
105
+ return answer;
106
+ },
107
+ },
108
+ };
109
+ }
110
+
111
+ export function saveAnswersFile(path: string, answers: SetupAnswerValues): void {
112
+ writeFileSync(path, `${JSON.stringify(answers, null, 2)}\n`, "utf8");
113
+ }
114
+
115
+ /** Turns exhausted scripted stdin into an actionable non-interactive failure. */
116
+ export function guardNonInteractiveUi(inner: TerminalUi): TerminalUi {
117
+ const exhausted = (title: string, options: PromptOptions): never => {
118
+ throw new Error(
119
+ `stdin ended before ${promptName(title, options)}; provide more piped answers or use setup --answers FILE`,
120
+ );
121
+ };
122
+ return {
123
+ notify: (message, type) => inner.notify(message, type),
124
+ close: (message) => inner.close(message),
125
+ async confirm(title, message, options) {
126
+ return (await inner.confirm(title, message, options)) ?? exhausted(title, options);
127
+ },
128
+ async input(title, placeholder, options) {
129
+ return (await inner.input(title, placeholder, options)) ?? exhausted(title, options);
130
+ },
131
+ async select(title, choices, options: SelectOptions) {
132
+ return (await inner.select(title, choices, options)) ?? exhausted(title, options);
133
+ },
134
+ };
135
+ }
@@ -275,6 +275,7 @@ export async function runHostInstall(
275
275
  const outcome = await runPrivileged(plan.steps, ui, {
276
276
  ...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
277
277
  title: "Install and start the supervised session?",
278
+ answerKey: "install-host",
278
279
  preamble: [
279
280
  `Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
280
281
  ...(plan.herdrUnit === undefined
@@ -547,6 +548,7 @@ export async function runGraphInstall(
547
548
  const outcome = await runPrivileged([...clones, ...install], ui, {
548
549
  ...(options.privileged === undefined ? {} : { deps: options.privileged }),
549
550
  title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
551
+ answerKey: `install-code-graph.${project.name}`,
550
552
  preamble: [
551
553
  `Staged: ${staged.written.join(", ")}.`,
552
554
  ...(clones.length === 0
@@ -402,6 +402,7 @@ export async function probeProse(
402
402
  const keep = await ui.confirm(
403
403
  `Keep this ${heading}?`,
404
404
  "It becomes part of POLICY.md, which the orchestrator re-reads on every tick. You can edit that file afterwards. Declining keeps the stub.",
405
+ { key: `keep-probe.${name}` },
405
406
  );
406
407
  // A dismissed prompt is not a yes. `undefined` means the operator walked away.
407
408
  if (keep !== true) {