omp-conductor 0.16.1 → 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.
@@ -0,0 +1,480 @@
1
+ import { COMMAND_DETAILS, COMMAND_HELP_TAIL } from "./command-help.ts";
2
+ import { AMEND_AREA_IDS } from "./setup.ts";
3
+ import type { CommandScope } from "./commands/context.ts";
4
+
5
+ export type CommandCompleter = "project" | "shell" | "area";
6
+
7
+ export interface CommandFlag {
8
+ name: string;
9
+ alias?: string;
10
+ description: string;
11
+ takesValue: boolean;
12
+ completer?: CommandCompleter;
13
+ }
14
+
15
+ export interface CommandPositional {
16
+ name: string;
17
+ completer?: CommandCompleter;
18
+ variadic?: boolean;
19
+ }
20
+
21
+ export interface CommandManifestEntry {
22
+ name: string;
23
+ description: string;
24
+ scope: CommandScope;
25
+ usage: readonly string[];
26
+ subcommands?: readonly { name: string; description: string }[];
27
+ flags: readonly CommandFlag[];
28
+ positionals?: readonly CommandPositional[];
29
+ /** Long-form operator help, kept on the manifest so generated help cannot drift. */
30
+ details?: string;
31
+ }
32
+
33
+ const project = (): CommandFlag => ({
34
+ name: "--project",
35
+ description: "target the named configured project",
36
+ takesValue: true,
37
+ completer: "project",
38
+ });
39
+ const all = (): CommandFlag => ({
40
+ name: "--all",
41
+ description: "target every configured project",
42
+ takesValue: false,
43
+ });
44
+ const value = (
45
+ name: string,
46
+ description: string,
47
+ completer?: CommandCompleter,
48
+ ): CommandFlag => ({ name, description, takesValue: true, completer });
49
+ const toggle = (name: string, description: string, alias?: string): CommandFlag => ({
50
+ name,
51
+ alias,
52
+ description,
53
+ takesValue: false,
54
+ });
55
+
56
+ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
57
+ {
58
+ name: "setup",
59
+ description: "interview, configure the fleet, or install host and graph surfaces",
60
+ scope: "project",
61
+ usage: [
62
+ "setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]",
63
+ "setup host [NAME] [--project NAME]",
64
+ "setup graph [--no-seed] [--print] [--project NAME]",
65
+ ],
66
+ details: COMMAND_DETAILS,
67
+ subcommands: [
68
+ { name: "host", description: "stage and install the host services" },
69
+ { name: "graph", description: "install and seed code-graph indexes" },
70
+ ],
71
+ flags: [
72
+ project(),
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"),
76
+ toggle("--no-seed", "enable graph indexing without the initial seed"),
77
+ toggle("--print", "print the graph install plan without changing anything"),
78
+ ],
79
+ positionals: [
80
+ { name: AMEND_AREA_IDS.join("|"), completer: "area" },
81
+ { name: "project", completer: "project" },
82
+ ],
83
+ },
84
+ {
85
+ name: "start",
86
+ description: "start the installed service or background dispatch daemon",
87
+ scope: "host",
88
+ usage: ["start [--port N] [--project NAME]"],
89
+ flags: [value("--port", "health endpoint port"), project()],
90
+ },
91
+ {
92
+ name: "version",
93
+ description: "print the installed package version (also --version, -V)",
94
+ scope: "none",
95
+ usage: ["--version"],
96
+ flags: [],
97
+ },
98
+ {
99
+ name: "stop",
100
+ description: "hold the fleet and stop the dispatch daemon",
101
+ scope: "fleet",
102
+ usage: ["stop [--pane] [--project NAME | --all]"],
103
+ flags: [toggle("--pane", "also stop and pin off pane recovery"), project(), all()],
104
+ },
105
+ {
106
+ name: "restart",
107
+ description: "drain and restart the shared dispatch daemon",
108
+ scope: "host",
109
+ usage: ["restart [--now] [--timeout SECONDS] [--port N] [--project NAME]"],
110
+ flags: [
111
+ toggle("--now", "restart immediately without draining workers"),
112
+ value("--timeout", "maximum drain time in seconds"),
113
+ value("--port", "health endpoint port"),
114
+ project(),
115
+ ],
116
+ },
117
+ {
118
+ name: "upgrade",
119
+ description: "upgrade all installed conductor surfaces as one transaction",
120
+ scope: "host",
121
+ usage: ["upgrade [--to VERSION] [--project NAME]"],
122
+ flags: [value("--to", "package version to install"), project()],
123
+ },
124
+ {
125
+ name: "upgrade-install",
126
+ description: "run the detached upgrade transaction",
127
+ scope: "host",
128
+ usage: ["upgrade-install --to VERSION [--project NAME]"],
129
+ flags: [value("--to", "package version to install"), project()],
130
+ },
131
+ {
132
+ name: "upgrade-rollback",
133
+ description: "restore surfaces from the durable pre-upgrade snapshot",
134
+ scope: "host",
135
+ usage: ["upgrade-rollback"],
136
+ flags: [],
137
+ },
138
+ {
139
+ name: "board",
140
+ description: "open the live keyboard-driven fleet board",
141
+ scope: "fleet",
142
+ usage: ["board [--project NAME] [--json]"],
143
+ flags: [project(), toggle("--json", "print the stable board JSON shape")],
144
+ },
145
+ {
146
+ name: "dashboard",
147
+ description: "serve the read-only browser dashboard",
148
+ scope: "none",
149
+ usage: ["dashboard [--port N] [--host ADDR]"],
150
+ flags: [value("--port", "dashboard port"), value("--host", "dashboard bind address")],
151
+ },
152
+ {
153
+ name: "status",
154
+ description: "print layered fleet and deployment status",
155
+ scope: "fleet",
156
+ usage: ["status [--project NAME] [--json]"],
157
+ flags: [project(), toggle("--json", "print the stable status JSON shape")],
158
+ },
159
+ {
160
+ name: "stats",
161
+ description: "summarize fleet throughput, failures, spend, and GitHub calls",
162
+ scope: "project",
163
+ usage: ["stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]"],
164
+ flags: [
165
+ value("--since", "reporting window start"),
166
+ project(),
167
+ toggle("--json", "print the stable stats JSON shape"),
168
+ ],
169
+ },
170
+ {
171
+ name: "doctor",
172
+ description: "run read-only deployment health checks",
173
+ scope: "fleet",
174
+ usage: ["doctor [--project NAME] [--json] [--probe-telegram]"],
175
+ flags: [
176
+ project(),
177
+ toggle("--json", "print the stable doctor JSON shape"),
178
+ toggle("--probe-telegram", "send the self-identified Telegram health probe"),
179
+ ],
180
+ },
181
+ {
182
+ name: "restore-db",
183
+ description: "restore conductor.db from a safe snapshot",
184
+ scope: "host",
185
+ usage: ["restore-db [SNAPSHOT]"],
186
+ flags: [],
187
+ positionals: [{ name: "snapshot" }],
188
+ },
189
+ {
190
+ name: "ledger",
191
+ description: "print conductor verb and daemon decision history",
192
+ scope: "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
+ ],
200
+ },
201
+ {
202
+ name: "hold",
203
+ description: "pause claims and disarm ticks while keeping processes up",
204
+ scope: "fleet",
205
+ usage: ["hold [--keep-ticks] [--project NAME | --all]"],
206
+ flags: [toggle("--keep-ticks", "pause claims without disarming ticks"), project(), all()],
207
+ },
208
+ {
209
+ name: "arm",
210
+ description: "prove Telegram delivery and arm scheduled ticks",
211
+ scope: "fleet",
212
+ usage: ["arm [--project NAME | --all]"],
213
+ flags: [project(), all()],
214
+ },
215
+ {
216
+ name: "disarm",
217
+ description: "disarm scheduled ticks without stopping processes",
218
+ scope: "fleet",
219
+ usage: ["disarm [--project NAME | --all]"],
220
+ flags: [project(), all()],
221
+ },
222
+ {
223
+ name: "tail",
224
+ description: "follow the newest worker run for an issue",
225
+ scope: "project",
226
+ usage: ["tail <issue> [--project NAME]"],
227
+ flags: [project()],
228
+ positionals: [{ name: "issue" }],
229
+ },
230
+ {
231
+ name: "extend",
232
+ description: "raise a live or next-attempt worker turn ceiling",
233
+ scope: "project",
234
+ usage: ["extend <issue> --turns N [--project NAME]"],
235
+ flags: [value("--turns", "positive turn ceiling"), project()],
236
+ positionals: [{ name: "issue" }],
237
+ },
238
+ {
239
+ name: "worker",
240
+ description: "pause, resume, or stop one worker session",
241
+ scope: "project",
242
+ usage: [
243
+ "worker pause <issue> [--project NAME]",
244
+ "worker resume <issue> [--project NAME]",
245
+ "worker stop <issue> --reason TEXT [--project NAME]",
246
+ ],
247
+ subcommands: [
248
+ { name: "pause", description: "park the worker at harness idle" },
249
+ { name: "resume", description: "resume the parked worker" },
250
+ { name: "stop", description: "stop the worker with a recorded reason" },
251
+ ],
252
+ flags: [value("--reason", "reason for stopping the worker"), project()],
253
+ positionals: [{ name: "action" }, { name: "issue" }],
254
+ },
255
+ {
256
+ name: "unblock",
257
+ description: "clear blocked and failed labels so an issue can be claimed again",
258
+ scope: "project",
259
+ usage: ["unblock <issue> [--force] [--no-requeue] [--project NAME]"],
260
+ flags: [
261
+ toggle("--force", "accept missing salvage and continue"),
262
+ toggle("--no-requeue", "clear state labels without restoring the queue label"),
263
+ project(),
264
+ ],
265
+ positionals: [{ name: "issue" }],
266
+ },
267
+ {
268
+ name: "unfreeze",
269
+ description: "clear a repository freeze with a recorded reason",
270
+ scope: "project",
271
+ usage: ["unfreeze <repo> [--reason TEXT] [--project NAME]"],
272
+ flags: [value("--reason", "reason for clearing the freeze"), project()],
273
+ positionals: [{ name: "repo" }],
274
+ },
275
+ {
276
+ name: "verb",
277
+ description: "run a gated conductor_* verb as the orchestrator",
278
+ scope: "project",
279
+ usage: ["verb <conductor_*> [--project NAME] [--arg k=v ...]"],
280
+ flags: [project(), value("--arg", "repeatable conductor verb argument")],
281
+ positionals: [{ name: "conductor-verb" }],
282
+ },
283
+ {
284
+ name: "daemon",
285
+ description: "run the dispatch loop in the foreground",
286
+ scope: "host",
287
+ usage: ["daemon [--once] [--port N] [--project NAME]"],
288
+ flags: [toggle("--once", "run one tick and exit"), value("--port", "health endpoint port"), project()],
289
+ },
290
+ {
291
+ name: "resume",
292
+ description: "clear fleet pause and pane recovery pins without arming ticks",
293
+ scope: "fleet",
294
+ usage: ["resume [--project NAME | --all]"],
295
+ flags: [project(), all()],
296
+ },
297
+ {
298
+ name: "brief-upgrade",
299
+ description: "inspect or migrate the project brief overlay",
300
+ scope: "project",
301
+ usage: ["brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]"],
302
+ flags: [
303
+ toggle("--migrate", "migrate the owned brief half into POLICY.md"),
304
+ toggle("--retrofit", "add the ownership cut to an older brief"),
305
+ toggle("--apply", "write the proposed brief changes"),
306
+ value("--file", "brief path"),
307
+ project(),
308
+ ],
309
+ },
310
+ {
311
+ name: "friction",
312
+ description: "record a bounded escalation or report-quality observation",
313
+ scope: "project",
314
+ usage: [
315
+ "friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]",
316
+ ],
317
+ flags: [value("--detail", "bounded observation detail"), value("--issue", "related issue number"), project()],
318
+ positionals: [{ name: "category" }],
319
+ },
320
+ {
321
+ name: "event",
322
+ description: "persist one ordinary material outcome without sending it",
323
+ scope: "project",
324
+ usage: [
325
+ "event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]",
326
+ ],
327
+ subcommands: [{ name: "record", description: "record a material outcome" }],
328
+ flags: [
329
+ value("--category", "short lowercase event category"),
330
+ value("--summary", "event summary"),
331
+ value("--evidence", "verifiable evidence reference"),
332
+ value("--occurred-at", "ISO occurrence timestamp"),
333
+ project(),
334
+ ],
335
+ positionals: [{ name: "action" }],
336
+ },
337
+ {
338
+ name: "report",
339
+ description: "hand a rendered report to the daemon's durable outbox",
340
+ scope: "project",
341
+ usage: [
342
+ "report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]",
343
+ ],
344
+ flags: [
345
+ value("--text", "rendered report text"),
346
+ value("--kind", "material, digest, or escalation category"),
347
+ value("--events", "comma-separated event ledger ids"),
348
+ value("--notices", "comma-separated notice ledger ids"),
349
+ project(),
350
+ ],
351
+ },
352
+ {
353
+ name: "message",
354
+ description: "deliver one direct message to the project's Telegram topic",
355
+ scope: "project",
356
+ usage: ["message --text TEXT [--category CATEGORY] [--blocks TEXT] [--project NAME]"],
357
+ flags: [
358
+ value("--text", "message text"),
359
+ value("--category", "escalation category"),
360
+ value("--blocks", "what the message blocks"),
361
+ project(),
362
+ ],
363
+ },
364
+ {
365
+ name: "decision",
366
+ description: "record, list, resolve, and withdraw operator decisions",
367
+ scope: "project",
368
+ usage: [
369
+ "decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
370
+ "decision resolve <id> --answer TEXT [--project NAME]",
371
+ "decision withdraw <id> [--reason TEXT] [--project NAME]",
372
+ "decision list [--project NAME] [--json]",
373
+ ],
374
+ subcommands: [
375
+ { name: "open", description: "record a question for the operator" },
376
+ { name: "resolve", description: "record the operator's answer" },
377
+ { name: "withdraw", description: "withdraw an obsolete question" },
378
+ { name: "list", description: "list open operator decisions" },
379
+ ],
380
+ flags: [
381
+ value("--question", "question for the operator"),
382
+ value("--blocks", "what the decision blocks"),
383
+ value("--resolves-when", "automatic resolution condition"),
384
+ value("--answer", "operator answer"),
385
+ value("--reason", "withdrawal reason"),
386
+ toggle("--json", "print decision list as stable JSON"),
387
+ project(),
388
+ ],
389
+ positionals: [{ name: "action" }, { name: "id" }],
390
+ },
391
+ {
392
+ name: "watch",
393
+ description: "record or list orchestrator-only conditions and carry notes",
394
+ scope: "project",
395
+ usage: [
396
+ "watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
397
+ "watch list [--project NAME] [--json]",
398
+ ],
399
+ subcommands: [
400
+ { name: "add", description: "record a watch" },
401
+ { name: "list", description: "list open watches" },
402
+ ],
403
+ flags: [
404
+ value("--note", "note carried when the watch resolves"),
405
+ value("--blocks", "what the watch blocks"),
406
+ value("--resolves-when", "automatic resolution condition"),
407
+ toggle("--json", "print watch list as stable JSON"),
408
+ project(),
409
+ ],
410
+ positionals: [{ name: "action" }],
411
+ },
412
+ {
413
+ name: "intake",
414
+ description: "capture and groom raw ideas durably",
415
+ scope: "project",
416
+ usage: [
417
+ 'intake "<text>" [--project NAME]',
418
+ "intake list [--project NAME]",
419
+ "intake dismiss <id> [--project NAME]",
420
+ "intake groomed <id> --issue <url> [--project NAME]",
421
+ ],
422
+ subcommands: [
423
+ { name: "list", description: "list pending intake items" },
424
+ { name: "dismiss", description: "dismiss an intake item" },
425
+ { name: "groomed", description: "link an intake item to its issue" },
426
+ ],
427
+ flags: [value("--issue", "groomed issue URL"), project()],
428
+ positionals: [{ name: "text|action" }, { name: "id" }],
429
+ },
430
+ {
431
+ name: "help",
432
+ description: "print command usage (also --help, -h)",
433
+ scope: "none",
434
+ usage: ["help"],
435
+ details: COMMAND_HELP_TAIL,
436
+ flags: [],
437
+ },
438
+ {
439
+ name: "complete",
440
+ description: "generate shell setup or answer one completion request",
441
+ scope: "none",
442
+ usage: ["complete <zsh|bash|fish|powershell>", "complete -- <args...>"],
443
+ flags: [],
444
+ positionals: [
445
+ { name: "shell", completer: "shell" },
446
+ { name: "args", variadic: true },
447
+ ],
448
+ },
449
+ ];
450
+
451
+ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_MANIFEST): string {
452
+ const synopsis = manifest.flatMap((command) =>
453
+ command.usage.map((line) => ` omp-conductor ${line}`),
454
+ );
455
+ const width = Math.max(...manifest.map((command) => command.name.length));
456
+ const commands = manifest.map(
457
+ (command) => ` ${command.name.padEnd(width)} ${command.description}`,
458
+ );
459
+ const details = manifest.flatMap((command) =>
460
+ command.details === undefined ? [] : [...command.details.split("\n"), ""],
461
+ );
462
+ return [
463
+ "omp-conductor — dispatch ready issues to omp coding sessions",
464
+ "",
465
+ "usage:",
466
+ ...synopsis,
467
+ "",
468
+ "commands:",
469
+ ...commands,
470
+ "",
471
+ "details:",
472
+ ...details,
473
+ "recipes:",
474
+ " hold no claims, no tick sends (inspectable)",
475
+ " stop hold + stop dispatch daemon",
476
+ " stop --pane stop + pin conductor-pane recovery off",
477
+ " resume clear pause and pane pin (ticks stay disarmed)",
478
+ " resume && arm clear pause and pane pin, then prove inbound Telegram",
479
+ ].join("\n");
480
+ }
@@ -8,11 +8,16 @@
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
+ const r = await withProgress(
16
+ "arm: sending inbound Telegram challenge…",
17
+ "Inbound Telegram round-trip proved",
18
+ () => armTicks(project.name),
19
+ { plainMessage: true },
20
+ );
16
21
  process.stdout.write(
17
22
  `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
18
23
  `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
@@ -0,0 +1,93 @@
1
+ import { RootCommand, type Complete } from "@bomb.sh/tab";
2
+ import { COMMAND_MANIFEST, type CommandCompleter } from "../command-manifest.ts";
3
+ import { loadConfig } from "../config.ts";
4
+ import { AMEND_AREA_IDS } from "../setup.ts";
5
+
6
+ export const COMPLETION_SHELLS = ["zsh", "bash", "fish", "powershell"] as const;
7
+ export type CompletionShell = (typeof COMPLETION_SHELLS)[number];
8
+
9
+ function suggestions(kind: CommandCompleter, complete: Complete): void {
10
+ try {
11
+ if (kind === "project") {
12
+ for (const project of loadConfig().projects) complete(project.name, "configured project");
13
+ return;
14
+ }
15
+ if (kind === "area") {
16
+ for (const area of AMEND_AREA_IDS) complete(area, "setup area");
17
+ return;
18
+ }
19
+ for (const shell of COMPLETION_SHELLS) complete(shell, "supported shell");
20
+ } catch {
21
+ // Completion runs on every TAB press. A missing or invalid config must make
22
+ // completion empty, never turn an interactive shell into an error surface.
23
+ }
24
+ }
25
+
26
+ export function completionRoot(): RootCommand {
27
+ const root = new RootCommand();
28
+ for (const entry of COMMAND_MANIFEST) {
29
+ const command = root.command(entry.name, entry.description);
30
+ for (const flag of entry.flags) {
31
+ const name = flag.name.replace(/^--/, "");
32
+ const alias = flag.alias?.replace(/^-+/, "");
33
+ const completer = flag.completer;
34
+ const complete =
35
+ completer === undefined ? undefined : ((add: Complete) => suggestions(completer, add));
36
+ if (flag.takesValue) {
37
+ command.option(name, flag.description, complete ?? (() => undefined), alias);
38
+ } else if (alias !== undefined) {
39
+ command.option(name, flag.description, alias);
40
+ } else {
41
+ command.option(name, flag.description);
42
+ }
43
+ }
44
+ for (const [index, positional] of (entry.positionals ?? []).entries()) {
45
+ const subcommands = index === 0 ? entry.subcommands : undefined;
46
+ command.argument(
47
+ positional.name,
48
+ positional.completer === undefined && subcommands === undefined
49
+ ? undefined
50
+ : (complete) => {
51
+ for (const subcommand of subcommands ?? []) {
52
+ complete(subcommand.name, subcommand.description);
53
+ }
54
+ if (positional.completer !== undefined) suggestions(positional.completer, complete);
55
+ },
56
+ positional.variadic,
57
+ );
58
+ }
59
+ }
60
+ return root;
61
+ }
62
+
63
+ function shellFrom(value: string | undefined): CompletionShell {
64
+ if ((COMPLETION_SHELLS as readonly string[]).includes(value ?? "")) {
65
+ return value as CompletionShell;
66
+ }
67
+ throw new Error(`complete expects one of: ${COMPLETION_SHELLS.join(", ")}`);
68
+ }
69
+
70
+ export function printCompletionScript(shell: CompletionShell): void {
71
+ completionRoot().setup("omp-conductor", "omp-conductor", shell);
72
+ }
73
+
74
+ export function renderCompletionScript(shell: CompletionShell): string {
75
+ const lines: string[] = [];
76
+ const original = console.log;
77
+ console.log = (...args: unknown[]) => lines.push(args.map(String).join(" "));
78
+ try {
79
+ printCompletionScript(shell);
80
+ } finally {
81
+ console.log = original;
82
+ }
83
+ return `${lines.join("\n")}\n`;
84
+ }
85
+
86
+ export function completeCommand(args: string[]): void {
87
+ if (args[0] === "--") {
88
+ const request = args[1] === "omp-conductor" ? args.slice(2) : args.slice(1);
89
+ completionRoot().parse(request);
90
+ return;
91
+ }
92
+ printCompletionScript(shellFrom(args[0]));
93
+ }
@@ -115,6 +115,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
115
115
  status: "fleet",
116
116
  stop: "fleet",
117
117
  // none — no project at all
118
+ complete: "none",
118
119
  dashboard: "none",
119
120
  help: "none",
120
121
  "--help": "none",
@@ -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
  }