omp-conductor 0.16.0 → 0.16.2

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/src/cli.ts CHANGED
@@ -9,8 +9,7 @@
9
9
  */
10
10
  import { userInfo } from "node:os";
11
11
  import { loadConfig } from "./config.ts";
12
- import { AMEND_AREA_IDS } from "./setup.ts";
13
- import type { ProjectConfig } from "./types.ts";
12
+ import { COMMAND_MANIFEST, renderUsage } from "./command-manifest.ts";
14
13
  import { armCommand } from "./commands/arm.ts";
15
14
  import { boardCommand } from "./commands/board.ts";
16
15
  import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
@@ -53,276 +52,7 @@ import {
53
52
  type CommandHandler,
54
53
  } from "./commands/context.ts";
55
54
 
56
- const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
57
-
58
- usage:
59
- omp-conductor setup [area] [--no-ai] [--project NAME]
60
- omp-conductor start [--port N] [--project NAME]
61
- omp-conductor --version
62
- omp-conductor stop [--pane] [--project NAME | --all]
63
- omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
64
- omp-conductor upgrade [--to VERSION] [--project NAME]
65
- omp-conductor upgrade-install --to VERSION [--project NAME]
66
- omp-conductor upgrade-rollback
67
- omp-conductor board [--project NAME] [--json]
68
- omp-conductor dashboard [--port N] [--host ADDR]
69
- omp-conductor status [--project NAME]
70
- omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
71
- omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
72
- omp-conductor restore-db [SNAPSHOT]
73
- omp-conductor ledger [--issue N] [--limit N] [--project NAME]
74
- omp-conductor hold [--keep-ticks] [--project NAME | --all]
75
- omp-conductor arm [--project NAME | --all]
76
- omp-conductor disarm [--project NAME | --all]
77
- omp-conductor tail <issue> [--project NAME]
78
- omp-conductor extend <issue> --turns N [--project NAME]
79
- omp-conductor worker pause <issue> [--project NAME]
80
- omp-conductor worker resume <issue> [--project NAME]
81
- omp-conductor worker stop <issue> --reason TEXT [--project NAME]
82
- omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
83
- omp-conductor unfreeze <repo> [--reason TEXT] [--project NAME]
84
- omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
85
- omp-conductor daemon [--once] [--port N] [--project NAME]
86
- omp-conductor resume [--project NAME | --all]
87
- omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
88
- omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
89
- omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
90
- omp-conductor report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]
91
- omp-conductor message --text TEXT [--category CATEGORY] [--blocks TEXT] [--project NAME]
92
- omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
93
- omp-conductor decision resolve <id> --answer TEXT [--project NAME]
94
- omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
95
- omp-conductor decision list [--project NAME]
96
- omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
97
- omp-conductor watch list [--project NAME]
98
- omp-conductor intake "<text>" [--project NAME]
99
- omp-conductor intake list [--project NAME]
100
- omp-conductor intake dismiss <id> [--project NAME]
101
- omp-conductor intake groomed <id> --issue <url> [--project NAME]
102
- omp-conductor help
103
-
104
- setup interview, then write config.json, the labels, the briefs and the
105
- staged host files behind one confirm. Bare is a full run — or, when
106
- the project already exists, a chooser of which area to amend. Naming
107
- an area positionally skips that chooser and amends only that area:
108
- ${AMEND_AREA_IDS.join(", ")}.
109
- upgrade update the Bun-global CLI, omp plugin, Herdr recovery plugin, and
110
- brief as one pinned release. Pauses only new claims, drains live
111
- workers, reloads, verifies twice, and restores the prior dispatch
112
- state. Run it from a shell outside the target Herdr session.
113
- upgrade-install
114
- the detached executor half of the fleet-installs-itself request:
115
- the same transaction, journaled per surface, leaving verification
116
- and dispatch restore to the first tick after the restart. Run by
117
- the transient unit, never by hand in a Herdr session.
118
- upgrade-rollback
119
- the detached rollback unit: restore every surface the failed
120
- upgrade-install touched, from the durable pre-install snapshot in
121
- the upgrade journal, and report through the durable outbox.
122
- start start the installed herdr-fleet.service when present, then run the
123
- dispatch loop in the background and wait until it answers GET
124
- /healthz on :8787 (override with --port). Refuses if one is running.
125
- stop stop the conductor: pause claiming, disarm ticks, then stop the
126
- dispatch daemon (systemctl-aware: an explicit stop is recorded by
127
- systemd, so Restart=always does not undo it). Pane stays up unless
128
- --pane is passed. To bounce the daemon without stopping the fleet,
129
- use restart.
130
- stop --pane
131
- also stop the conductor agent's pane and pin herdr-conductor recovery
132
- off for that agent only — it does NOT stop herdr-fleet.service or any
133
- other herdr session. resume clears the pin.
134
- restart drain the fleet by default: pause new claims, wait until live workers
135
- reach 0/N (bounded by --timeout SECONDS, default 1800), restart keeping
136
- the running daemon's port and project unless a flag overrides them,
137
- then restore the prior dispatch state. --now skips the drain and
138
- restarts immediately, orphaning any live runs (old behavior). A drain
139
- that hits --timeout restarts nothing and leaves dispatch paused. On boot
140
- the new process salvages dirty live worktrees before orphaning those
141
- rows — see README "Deploying a new package onto a busy fleet". Goes
142
- through systemctl when the unit owns the live pid; a failed
143
- installed unit is reset and restarted through systemd, never
144
- replaced by an unmanaged daemon.
145
- status layered fleet report: dispatch (running|paused|stopped), ticks and
146
- next due time, pane, herdr, Telegram bot/API health, daemon, caps
147
- and active runs.
148
- stats what the fleet accomplished and at what cost, read wholly from the
149
- local store: issues merged, merge rate, queue-to-merge lead time
150
- (median/p90), attempts per merged issue, metered spend per merged
151
- issue, the failure-class breakdown of what did not merge, and
152
- tracked gh calls over the window. Continuation chains collapse into
153
- one journey; $0.00 runs are reported as unmetered, never averaged in
154
- as free. --json prints the stable {project, window, ghCalls, empty,
155
- total, repos} shape.
156
- doctor read-only deployment health, one finding per past failure mode:
157
- gh auth/scopes, exact-case configured labels, systemd unit drift
158
- and runtime-dir ownership, config + backup freshness, sqlite
159
- integrity, spend telemetry, reporting timezones, Telegram health.
160
- The only write anywhere is the self-identified probe message that
161
- --probe-telegram sends through the report transport. Exit code 0
162
- only when nothing failed; --json prints the stable CI shape. Run
163
- it after install and after every upgrade.
164
- restore-db
165
- put conductor.db back to a restorable snapshot, replacing the live
166
- file and dropping stale -wal/-shm sidecars. Refuses while a daemon
167
- is live — restoring under a running daemon is a corruption path.
168
- The default source is the newest snapshot in the configured db
169
- backup directory; an explicit snapshot path overrides it.
170
- board open the live keyboard-driven fleet board. It renders queue holds,
171
- every run lifecycle stage, recent merges, spend and health; Enter
172
- follows a selected transcript without leaving the board. --json (or
173
- a non-interactive stdin/stdout) prints a one-shot JSON snapshot of
174
- the same lanes instead.
175
- dashboard
176
- serve the fleet dashboard in a browser: the UI shell and GET
177
- /api/projects, bearer-authenticated with the token minted 0600 at
178
- <state>/dashboard-token on first start. Binds 127.0.0.1:8788 by
179
- default; a non-loopback --host still starts but prints a warning
180
- naming the token file. A separate process — never a route on the
181
- daemon port.
182
- ledger every conductor-verb call and how the daemon decided it: the verb,
183
- the arguments, allow or refuse, the named refusal reason, and the
184
- resulting sha. Sessions cannot push, open, merge, label or release
185
- except through those verbs, so this is the record of what they tried
186
- as well as what they did. --issue narrows it to one issue's run.
187
- hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
188
- This is "stop the conductor overnight" without killing processes.
189
- Use --all to target every configured project.
190
- arm proof-gated: send a Telegram challenge and write the arm marker only
191
- after your reply appears as a user turn in the orchestrator transcript.
192
- Never auto-armed by resume/hold. Use --all for every project.
193
- disarm remove the arm marker so ticks skip. Processes untouched. Use --all
194
- to target every configured project.
195
- tail follow the newest run for <issue>: the worker's assistant text and
196
- the tools it calls, printed as they land. Workers are sessions inside
197
- the daemon rather than terminals, so this is the only way to watch
198
- one live. Runs until Ctrl-C, or until the run has finished and its
199
- transcript has stopped growing.
200
- extend raise a live run's turn ceiling, or set a bounded one-shot ceiling
201
- after a failed, killed, orphaned or blocked run. Refuses values outside
202
- configured bounds.
203
- worker cooperatively pause one live worker at harness idle, then resume the
204
- same session with a continuation prompt. Its wall clock is frozen
205
- while parked. Distinct from fleet-level pause/resume.
206
- unblock clear <issue>'s blocked and failed labels so the next tick can claim
207
- it again — the supported way back for an escalation you answered,
208
- and why the brief's "never hand-edit a state label" rule can stay
209
- absolute. Run history is kept; answered blocks consume the separate
210
- operational-continuation budget, not failed implementation attempts.
211
- Refuses when the newest attempt's work could not be committed and
212
- its worktree is the only copy: re-claiming removes that tree. Use
213
- --force once you have recovered it or accepted the loss. Restores
214
- the project queue label by default so the issue is actually
215
- dispatchable; --no-requeue clears state labels only (the "about to
216
- close it" case).
217
- verb run one conductor_* verb as the orchestrator, from the CLI: the same
218
- checks and the same ledger rows a session's call would get. This is
219
- how an external orchestrator merges, labels, releases or reads PR
220
- state without a raw gh call that skips every gate. Arguments are
221
- strings, one per --arg (e.g. --arg prUrl=https://x --arg headSha=y).
222
- A refusal exits 3. See conductor_pr_merge/conductor_label/
223
- conductor_release/conductor_pr_update_branch in the brief.
224
- event persist one ordinary material outcome without sending it. Category
225
- is a short lowercase slug; summary and evidence name what happened
226
- and where it can be verified. --occurred-at defaults to now.
227
- report hand a rendered report to the daemon's durable outbox. The report is
228
- persisted before anything is sent, delivered by the daemon with
229
- bounded retries, and shown by status until it lands. Delivery is
230
- at-least-once: a crash mid-send is retried and the retry says it may
231
- be a repeat. --kind digest is accepted at most once per local day,
232
- decided from the ledger rather than from what you remember sending.
233
- A digest associates the comma-separated --events and --notices rows
234
- atomically; omitted rows stay owed. The other --kind values (tier2,
235
- decision-needed, fleet-stopped, confirmed-failure) declare the
236
- report's interrupt category: the reporting policy decides between
237
- sending now and holding, exactly as for a daemon escalation. An
238
- identical retry is refused only while the earlier handoff is still
239
- undelivered, and admitted again once it lands (#453).
240
- message deliver one direct Telegram message to this project's own chat and
241
- forum topic, resolved from config rather than from whichever chat
242
- last wrote to the session. This is how a locally injected tick
243
- answers or asks something directly: telegram_send keeps the active
244
- topic only while it names no chat, and a tick has no active topic to
245
- keep. A question — text beginning "QUESTION:", or a --category that
246
- is not material — records an open decision row before delivery
247
- (parked on silence: still pending in every tick until answered or
248
- the seven-day expiry, so no one has to remember a separate
249
- \`decision open\`), and --category carries the escalation category
250
- directly instead of a text prefix. The operator's availability
251
- policy still decides between sending now and holding a notice,
252
- exactly as it does for an autonomous tick.
253
- decision record, list and close the questions you have put to your operator.
254
- A question that lives only in a session's context is lost to the next
255
- compaction, so \`decision open\` writes it down and every tick's prompt
256
- carries what is still owed. \`--resolves-when\` attaches a condition the
257
- daemon checks for you (pr-merged:<url>, issue-closed:<n>,
258
- npm-version:<pkg>@<version>); a met one is flagged in that digest.
259
- Unanswered rows expire after seven days rather than accumulating.
260
- decision open --question TEXT [--blocks TEXT] [--resolves-when COND]
261
- decision resolve <id> --answer TEXT
262
- decision withdraw <id> [--reason TEXT]
263
- decision list
264
- watch set a condition or carry note for the orchestrator itself, with no
265
- human in the loop — the same no-sleep surface \`decision open
266
- --resolves-when\` used to serve, given its own verb so it never renders
267
- as a question put to the operator. \`--resolves-when\` attaches a
268
- condition the daemon checks for you; a met watch wakes the next tick
269
- with its note, exactly as a met question does, but it is listed under
270
- its own heading and never under "Open operator decisions", and it has
271
- no seven-day expiry. \`watch list\` shows open watches.
272
- watch add --note TEXT [--blocks TEXT] [--resolves-when COND]
273
- watch list
274
- intake keep a raw idea durably before it becomes anything: record it now
275
- with \`omp-conductor intake "<text>"\`, list what is still pending,
276
- dismiss what turned out to be nothing. Backed by the sqlite store,
277
- not a session, so ideas survive restarts; the orchestrator grooms
278
- one into an issue as its #300 duty and records that provenance with
279
- \`omp-conductor intake groomed <id> --issue <url>\`.
280
- friction record a bounded observation the daemon cannot classify itself:
281
- an escalation that belonged in a digest, or a tick report that was
282
- noise/surprising. Repeated observations feed the existing Learning
283
- loop; recording one never edits policy by itself.
284
- daemon run the dispatch loop in the foreground; --once runs a single tick
285
- and exits. This is what \`start\` launches.
286
- resume clear pause and any pane-recovery pin. Does NOT re-arm: run arm after
287
- an inbound Telegram proof to bring ticks back. Use --all for every project.
288
- setup host [NAME]
289
- re-stage the systemd unit and run the install behind one confirm.
290
- The units are host-global; NAME (or --project NAME) says which
291
- project's per-project tail (tick config, brief link) to write, and
292
- is required on a host with several configured projects.
293
- setup graph
294
- set up the code-graph indexes workers query instead of grepping, end
295
- to end: check prerequisites, clone any missing index-only clone as
296
- you, install and enable the reindex timer as root, then seed one
297
- indexing run and verify it. --no-seed enables without seeding;
298
- --print changes nothing. Exits 1 when no repo has graphProject.
299
- brief-upgrade
300
- inspect the brief overlay (package floor + POLICY.md). Reports by
301
- default. --migrate lifts a bannered ORCHESTRATOR.md owned half into
302
- POLICY.md and recomposes. --retrofit proposes inserting the YOURS TO
303
- EDIT banner before the first Releases/Project context/Reporting/
304
- Amendments heading (#20); --retrofit --apply writes it. --apply only
305
- ever confirms one of those two — the legacy single-file merge was
306
- removed in 0.4.3. --file checks a brief that is not where the wizard
307
- would have put it.
308
- help print this text (also --help, -h).
309
- --version
310
- print the installed omp-conductor package version (also -V, version).
311
-
312
- Pause is a flag file under the state directory, so it applies to the selected
313
- project (or every configured project with --all) and survives a daemon restart.
314
- Hold also removes the arm marker the heartbeat reads, so both brains go quiet
315
- without killing processes. A running daemon is tracked by a pidfile under
316
- $OMP_CONDUCTOR_RUNTIME_DIR (default
317
- ~/.omp/run/daemons/omp-conductor), written whether it was started in the
318
- background or in the foreground, and probed for liveness on every read — a
319
- stale one never blocks a start.
320
-
321
- Stop the conductor:
322
- hold no claims, no tick sends (inspectable)
323
- stop hold + stop dispatch daemon
324
- stop --pane stop + pin conductor-pane recovery off
325
- resume && arm clear pause and any pane pin, then prove inbound Telegram`;
55
+ const USAGE = renderUsage(COMMAND_MANIFEST);
326
56
 
327
57
  /** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
328
58
  function flag(argv: string[], name: string): string | undefined {
@@ -413,46 +143,10 @@ function issueArg(verb: string, raw: string | undefined): number {
413
143
  return issue;
414
144
  }
415
145
 
416
-
417
- const argv = process.argv.slice(2);
418
- const cmd = argv[0];
419
- /**
420
- * Parsed once, because nearly every verb takes it and a per-case
421
- * `projectFlag` is a line each of them can forget. Named `projectFlag`
422
- * rather than `project` because many cases bind their own resolved
423
- * `ProjectConfig` under that name.
424
- */
425
- const projectFlag = flag(argv, "project");
426
- /**
427
- * The parsed essentials every command receives. Argument parsing lives above
428
- * (and behind) this object; a command module reads what it needs and never
429
- * imports this entry point, so no verb depends on another's edits here beyond
430
- * the table line that names it.
431
- */
432
- const ctx: CommandContext = {
433
- argv,
434
- projectFlag,
435
- flag: (name) => flag(argv, name),
436
- portFlag: () => portFlag(argv),
437
- turnsFlag: () => turnsFlag(argv),
438
- digestIdsFlag: (name) => digestIdsFlag(argv, name),
439
- issueArg: (verb, raw) => issueArg(verb, raw),
440
- targetProjects: () => targetProjects(),
441
- };
442
-
443
- function targetProjects(): ProjectConfig[] {
444
- // The fleet resolution, delegated to the scope machinery (#514) so the
445
- // arm/disarm/hold/resume/stop "every project, or the named one" shape is
446
- // the same code the classification describes it as.
447
- return resolveProjectsByScope(loadConfig(), "fleet", projectFlag, argv.includes("--all"));
448
- }
449
-
450
146
  /**
451
147
  * Removed verbs keep an entry rather than falling through to usage: an operator
452
148
  * who typed the old word gets the new one, not a wall of help text. Exit 2 is
453
- * the same code an unknown verb uses. Each is a two-line pointer with its own
454
- * fixed text and nothing to grow, so they stay as entries here instead of a
455
- * file per alias — the #462 acceptance carve-out for two-line cases.
149
+ * the same code an unknown verb uses.
456
150
  */
457
151
  const REMOVED: Record<string, string> = {
458
152
  halt: '"halt" is now "stop" — see help',
@@ -461,79 +155,114 @@ const REMOVED: Record<string, string> = {
461
155
  "graph-setup": '"graph-setup" is now "setup graph" — see help',
462
156
  };
463
157
 
464
- /**
465
- * The registration table verb → handler. Adding an operator subcommand is one
466
- * module in ./commands/ plus one line here; nothing else about dispatch
467
- * changes. Version and help are tiny enough that their handlers are single
468
- * statements, but they still ride the table so every surface is nameable.
469
- */
470
- const COMMANDS: Record<string, CommandHandler> = {
471
- "--version": () => versionCommand(),
472
- "-V": () => versionCommand(),
473
- version: () => versionCommand(),
474
- setup: () => setupCommand(ctx),
475
- upgrade: () => upgradeCommand(ctx),
476
- "upgrade-install": () => upgradeInstallCommand(ctx),
477
- "upgrade-rollback": () => upgradeRollbackCommand(ctx),
478
- daemon: () => daemonCommand(ctx),
479
- start: () => startCommand(ctx),
480
- stop: () => stopCommand(ctx),
481
- restart: () => restartCommand(ctx),
482
- status: () => statusCommand(ctx),
483
- stats: () => statsCommand(ctx),
484
- doctor: () => doctorCommand(ctx),
485
- ledger: () => ledgerCommand(ctx),
486
- board: () => boardCommand(ctx),
487
- dashboard: () => dashboardCommand(ctx),
488
- hold: () => holdCommand(ctx),
489
- arm: () => armCommand(ctx),
490
- disarm: () => disarmCommand(ctx),
491
- tail: () => tailCommand(ctx),
492
- extend: () => extendCommand(ctx),
493
- worker: () => workerCommand(ctx),
494
- unblock: () => unblockCommand(ctx),
495
- unfreeze: () => unfreezeCommand(ctx),
496
- verb: () => verbCommand(ctx),
497
- event: () => eventCommand(ctx),
498
- report: () => reportCommand(ctx),
499
- message: () => messageCommand(ctx),
500
- decision: () => decisionCommand(ctx),
501
- watch: () => watchCommand(ctx),
502
- intake: () => intakeCommand(ctx),
503
- friction: () => frictionCommand(ctx),
504
- "restore-db": () => restoreDbCommand(ctx),
505
- resume: () => resumeCommand(ctx),
506
- "brief-upgrade": () => briefUpgradeCommand(ctx),
507
- help: () => helpCommand(USAGE),
508
- "--help": () => helpCommand(USAGE),
509
- "-h": () => helpCommand(USAGE),
510
- };
158
+ /** The registration table used by dispatch and the manifest drift test. */
159
+ export function commandHandlers(ctx: CommandContext): Record<string, CommandHandler> {
160
+ return {
161
+ "--version": () => versionCommand(),
162
+ "-V": () => versionCommand(),
163
+ version: () => versionCommand(),
164
+ complete: async () =>
165
+ (await import("./commands/complete.ts")).completeCommand(ctx.argv.slice(1)),
166
+ setup: () => setupCommand(ctx),
167
+ upgrade: () => upgradeCommand(ctx),
168
+ "upgrade-install": () => upgradeInstallCommand(ctx),
169
+ "upgrade-rollback": () => upgradeRollbackCommand(ctx),
170
+ daemon: () => daemonCommand(ctx),
171
+ start: () => startCommand(ctx),
172
+ stop: () => stopCommand(ctx),
173
+ restart: () => restartCommand(ctx),
174
+ status: () => statusCommand(ctx),
175
+ stats: () => statsCommand(ctx),
176
+ doctor: () => doctorCommand(ctx),
177
+ ledger: () => ledgerCommand(ctx),
178
+ board: () => boardCommand(ctx),
179
+ dashboard: () => dashboardCommand(ctx),
180
+ hold: () => holdCommand(ctx),
181
+ arm: () => armCommand(ctx),
182
+ disarm: () => disarmCommand(ctx),
183
+ tail: () => tailCommand(ctx),
184
+ extend: () => extendCommand(ctx),
185
+ worker: () => workerCommand(ctx),
186
+ unblock: () => unblockCommand(ctx),
187
+ unfreeze: () => unfreezeCommand(ctx),
188
+ verb: () => verbCommand(ctx),
189
+ event: () => eventCommand(ctx),
190
+ report: () => reportCommand(ctx),
191
+ message: () => messageCommand(ctx),
192
+ decision: () => decisionCommand(ctx),
193
+ watch: () => watchCommand(ctx),
194
+ intake: () => intakeCommand(ctx),
195
+ friction: () => frictionCommand(ctx),
196
+ "restore-db": () => restoreDbCommand(ctx),
197
+ resume: () => resumeCommand(ctx),
198
+ "brief-upgrade": () => briefUpgradeCommand(ctx),
199
+ help: () => helpCommand(USAGE),
200
+ "--help": () => helpCommand(USAGE),
201
+ "-h": () => helpCommand(USAGE),
202
+ };
203
+ }
511
204
 
512
- try {
513
- const removed = cmd === undefined ? undefined : REMOVED[cmd];
514
- if (removed !== undefined) {
515
- process.stderr.write(`omp-conductor: ${removed}\n`);
516
- process.exit(2);
517
- }
518
- const handler = cmd === undefined ? undefined : COMMANDS[cmd];
519
- if (handler === undefined) {
520
- process.stderr.write(
521
- `${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
522
- );
523
- process.exit(2);
205
+ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<void> {
206
+ const cmd = argv[0];
207
+ // Completion runs on every TAB press. Answer before config, daemon, or
208
+ // project-context construction so a shell request stays cheap and fail-empty.
209
+ if (cmd === "complete") {
210
+ try {
211
+ const requestDelimiter = argv.indexOf("--", 1);
212
+ const ownArgs = argv.slice(1, requestDelimiter === -1 ? undefined : requestDelimiter);
213
+ const ownProjectFlag = flag(ownArgs, "project");
214
+ if (COMMAND_SCOPES[cmd] === "none" && ownProjectFlag !== undefined) {
215
+ process.stderr.write(
216
+ `omp-conductor: ${cmd} does not take a project — it needs no --project\n`,
217
+ );
218
+ process.exit(2);
219
+ }
220
+ (await import("./commands/complete.ts")).completeCommand(argv.slice(1));
221
+ return;
222
+ } catch (err) {
223
+ process.stderr.write(`omp-conductor: ${err instanceof Error ? err.message : String(err)}\n`);
224
+ process.exit(1);
225
+ }
524
226
  }
525
- // A "none"-scope verb does no per-project work, so --project cannot change
526
- // what it does: refuse it with a reason instead of silently ignoring it
527
- // (#514). Host-scoped verbs keep the flag because each one has a documented
528
- // per-project tail (or already refuses to narrow, #389).
529
- if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
530
- process.stderr.write(`omp-conductor: ${cmd} does not take a project — it needs no --project\n`);
531
- process.exit(2);
227
+ const projectFlag = flag(argv, "project");
228
+
229
+ const ctx: CommandContext = {
230
+ argv,
231
+ projectFlag,
232
+ flag: (name) => flag(argv, name),
233
+ portFlag: () => portFlag(argv),
234
+ turnsFlag: () => turnsFlag(argv),
235
+ digestIdsFlag: (name) => digestIdsFlag(argv, name),
236
+ issueArg: (verb, raw) => issueArg(verb, raw),
237
+ targetProjects: () =>
238
+ resolveProjectsByScope(loadConfig(), "fleet", projectFlag, argv.includes("--all")),
239
+ };
240
+ const commands = commandHandlers(ctx);
241
+
242
+ try {
243
+ const removed = cmd === undefined ? undefined : REMOVED[cmd];
244
+ if (removed !== undefined) {
245
+ process.stderr.write(`omp-conductor: ${removed}\n`);
246
+ process.exit(2);
247
+ }
248
+ const handler = cmd === undefined ? undefined : commands[cmd];
249
+ if (handler === undefined) {
250
+ process.stderr.write(
251
+ `${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
252
+ );
253
+ process.exit(2);
254
+ }
255
+ // A "none"-scope verb does no per-project work, so --project cannot change
256
+ // what it does: refuse it with a reason instead of silently ignoring it.
257
+ if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
258
+ process.stderr.write(`omp-conductor: ${cmd} does not take a project — it needs no --project\n`);
259
+ process.exit(2);
260
+ }
261
+ await handler(ctx);
262
+ } catch (err) {
263
+ process.stderr.write(`omp-conductor: ${err instanceof Error ? err.message : String(err)}\n`);
264
+ process.exit(1);
532
265
  }
533
- await handler(ctx);
534
- } catch (err) {
535
- // Config, lifecycle and `gh` errors are written to be read by a human, so
536
- // surface the message rather than a stack.
537
- process.stderr.write(`omp-conductor: ${err instanceof Error ? err.message : String(err)}\n`);
538
- process.exit(1);
539
266
  }
267
+
268
+ if (import.meta.main) await runCli();