@os-eco/overstory-cli 0.6.1 → 0.6.5

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 (110) hide show
  1. package/README.md +8 -7
  2. package/package.json +12 -4
  3. package/src/agents/checkpoint.test.ts +2 -2
  4. package/src/agents/hooks-deployer.test.ts +131 -16
  5. package/src/agents/hooks-deployer.ts +33 -1
  6. package/src/agents/identity.test.ts +27 -27
  7. package/src/agents/identity.ts +10 -10
  8. package/src/agents/lifecycle.test.ts +6 -6
  9. package/src/agents/lifecycle.ts +2 -2
  10. package/src/agents/manifest.test.ts +86 -0
  11. package/src/agents/overlay.test.ts +9 -9
  12. package/src/agents/overlay.ts +4 -4
  13. package/src/commands/agents.test.ts +8 -8
  14. package/src/commands/agents.ts +62 -91
  15. package/src/commands/clean.test.ts +36 -51
  16. package/src/commands/clean.ts +28 -49
  17. package/src/commands/completions.ts +14 -0
  18. package/src/commands/coordinator.test.ts +133 -26
  19. package/src/commands/coordinator.ts +101 -64
  20. package/src/commands/costs.test.ts +47 -47
  21. package/src/commands/costs.ts +96 -75
  22. package/src/commands/dashboard.test.ts +2 -2
  23. package/src/commands/dashboard.ts +75 -95
  24. package/src/commands/doctor.test.ts +2 -2
  25. package/src/commands/doctor.ts +92 -79
  26. package/src/commands/errors.test.ts +2 -2
  27. package/src/commands/errors.ts +56 -50
  28. package/src/commands/feed.test.ts +2 -2
  29. package/src/commands/feed.ts +86 -83
  30. package/src/commands/group.ts +167 -177
  31. package/src/commands/hooks.test.ts +2 -2
  32. package/src/commands/hooks.ts +52 -42
  33. package/src/commands/init.test.ts +19 -19
  34. package/src/commands/init.ts +7 -16
  35. package/src/commands/inspect.test.ts +18 -18
  36. package/src/commands/inspect.ts +55 -58
  37. package/src/commands/log.test.ts +26 -31
  38. package/src/commands/log.ts +97 -91
  39. package/src/commands/logs.test.ts +1 -1
  40. package/src/commands/logs.ts +101 -104
  41. package/src/commands/mail.test.ts +5 -5
  42. package/src/commands/mail.ts +157 -169
  43. package/src/commands/merge.test.ts +28 -66
  44. package/src/commands/merge.ts +21 -51
  45. package/src/commands/metrics.test.ts +8 -8
  46. package/src/commands/metrics.ts +34 -35
  47. package/src/commands/monitor.test.ts +3 -3
  48. package/src/commands/monitor.ts +57 -62
  49. package/src/commands/nudge.test.ts +1 -1
  50. package/src/commands/nudge.ts +41 -89
  51. package/src/commands/prime.test.ts +19 -51
  52. package/src/commands/prime.ts +13 -50
  53. package/src/commands/replay.test.ts +2 -2
  54. package/src/commands/replay.ts +79 -86
  55. package/src/commands/run.test.ts +1 -1
  56. package/src/commands/run.ts +97 -77
  57. package/src/commands/sling.test.ts +201 -5
  58. package/src/commands/sling.ts +37 -64
  59. package/src/commands/spec.test.ts +14 -40
  60. package/src/commands/spec.ts +32 -101
  61. package/src/commands/status.test.ts +97 -1
  62. package/src/commands/status.ts +63 -58
  63. package/src/commands/stop.test.ts +22 -40
  64. package/src/commands/stop.ts +18 -33
  65. package/src/commands/supervisor.test.ts +12 -14
  66. package/src/commands/supervisor.ts +144 -165
  67. package/src/commands/trace.test.ts +15 -15
  68. package/src/commands/trace.ts +59 -82
  69. package/src/commands/watch.test.ts +2 -2
  70. package/src/commands/watch.ts +38 -45
  71. package/src/commands/worktree.test.ts +213 -37
  72. package/src/commands/worktree.ts +110 -55
  73. package/src/config.test.ts +96 -0
  74. package/src/doctor/consistency.test.ts +14 -14
  75. package/src/doctor/databases.test.ts +22 -2
  76. package/src/doctor/databases.ts +16 -0
  77. package/src/doctor/dependencies.test.ts +55 -1
  78. package/src/doctor/dependencies.ts +113 -18
  79. package/src/doctor/merge-queue.test.ts +4 -4
  80. package/src/e2e/init-sling-lifecycle.test.ts +8 -8
  81. package/src/errors.ts +1 -1
  82. package/src/index.ts +223 -213
  83. package/src/logging/color.test.ts +74 -91
  84. package/src/logging/color.ts +52 -46
  85. package/src/logging/reporter.test.ts +10 -10
  86. package/src/logging/reporter.ts +6 -5
  87. package/src/mail/broadcast.test.ts +1 -1
  88. package/src/mail/client.test.ts +6 -6
  89. package/src/mail/store.test.ts +3 -3
  90. package/src/merge/queue.test.ts +73 -7
  91. package/src/merge/queue.ts +17 -2
  92. package/src/merge/resolver.test.ts +159 -7
  93. package/src/merge/resolver.ts +46 -2
  94. package/src/metrics/store.test.ts +44 -44
  95. package/src/metrics/store.ts +2 -2
  96. package/src/metrics/summary.test.ts +35 -35
  97. package/src/mulch/client.test.ts +1 -1
  98. package/src/schema-consistency.test.ts +239 -0
  99. package/src/sessions/compat.test.ts +3 -3
  100. package/src/sessions/compat.ts +2 -2
  101. package/src/sessions/store.test.ts +41 -4
  102. package/src/sessions/store.ts +13 -2
  103. package/src/types.ts +14 -14
  104. package/src/watchdog/daemon.test.ts +10 -10
  105. package/src/watchdog/daemon.ts +1 -1
  106. package/src/watchdog/health.test.ts +1 -1
  107. package/src/worktree/manager.test.ts +20 -20
  108. package/src/worktree/manager.ts +120 -4
  109. package/src/worktree/tmux.test.ts +98 -9
  110. package/src/worktree/tmux.ts +18 -0
@@ -23,9 +23,9 @@ import {
23
23
  buildCoordinatorBeacon,
24
24
  type CoordinatorDeps,
25
25
  coordinatorCommand,
26
+ createCoordinatorCommand,
26
27
  resolveAttach,
27
28
  } from "./coordinator.ts";
28
- import { isRunningAsRoot } from "./sling.ts";
29
29
 
30
30
  // --- Fake Tmux ---
31
31
 
@@ -41,6 +41,7 @@ interface TmuxCallTracker {
41
41
  killSession: Array<{ name: string }>;
42
42
  sendKeys: Array<{ name: string; keys: string }>;
43
43
  waitForTuiReady: Array<{ name: string }>;
44
+ ensureTmuxAvailable: number;
44
45
  }
45
46
 
46
47
  // --- Fake Watchdog ---
@@ -62,7 +63,13 @@ interface MonitorCallTracker {
62
63
  }
63
64
 
64
65
  /** Build a fake tmux DI object with configurable session liveness. */
65
- function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
66
+ function makeFakeTmux(
67
+ sessionAliveMap: Record<string, boolean> = {},
68
+ options: {
69
+ waitForTuiReadyResult?: boolean;
70
+ ensureTmuxAvailableError?: Error;
71
+ } = {},
72
+ ): {
66
73
  tmux: NonNullable<CoordinatorDeps["_tmux"]>;
67
74
  calls: TmuxCallTracker;
68
75
  } {
@@ -72,6 +79,7 @@ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
72
79
  killSession: [],
73
80
  sendKeys: [],
74
81
  waitForTuiReady: [],
82
+ ensureTmuxAvailable: 0,
75
83
  };
76
84
 
77
85
  const tmux: NonNullable<CoordinatorDeps["_tmux"]> = {
@@ -97,7 +105,13 @@ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
97
105
  },
98
106
  waitForTuiReady: async (name: string): Promise<boolean> => {
99
107
  calls.waitForTuiReady.push({ name });
100
- return true;
108
+ return options.waitForTuiReadyResult ?? true;
109
+ },
110
+ ensureTmuxAvailable: async (): Promise<void> => {
111
+ calls.ensureTmuxAvailable++;
112
+ if (options.ensureTmuxAvailableError) {
113
+ throw options.ensureTmuxAvailableError;
114
+ }
101
115
  },
102
116
  };
103
117
 
@@ -272,7 +286,7 @@ function makeCoordinatorSession(overrides: Partial<AgentSession> = {}): AgentSes
272
286
  capability: "coordinator",
273
287
  worktreePath: tempDir,
274
288
  branchName: "main",
275
- beadId: "",
289
+ taskId: "",
276
290
  tmuxSession: "overstory-test-project-coordinator",
277
291
  state: "working",
278
292
  pid: 99999,
@@ -310,13 +324,14 @@ function makeDeps(
310
324
  sessionAliveMap: Record<string, boolean> = {},
311
325
  watchdogConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
312
326
  monitorConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
327
+ tmuxOptions?: { waitForTuiReadyResult?: boolean; ensureTmuxAvailableError?: Error },
313
328
  ): {
314
329
  deps: CoordinatorDeps;
315
330
  calls: TmuxCallTracker;
316
331
  watchdogCalls: WatchdogCallTracker;
317
332
  monitorCalls: MonitorCallTracker;
318
333
  } {
319
- const { tmux, calls } = makeFakeTmux(sessionAliveMap);
334
+ const { tmux, calls } = makeFakeTmux(sessionAliveMap, tmuxOptions);
320
335
  const { watchdog, calls: watchdogCalls } = makeFakeWatchdog(
321
336
  watchdogConfig?.running,
322
337
  watchdogConfig?.startSuccess,
@@ -347,27 +362,33 @@ function makeDeps(
347
362
  describe("coordinatorCommand help", () => {
348
363
  test("--help outputs help text", async () => {
349
364
  const output = await captureStdout(() => coordinatorCommand(["--help"]));
350
- expect(output).toContain("overstory coordinator");
365
+ expect(output).toContain("coordinator");
351
366
  expect(output).toContain("start");
352
367
  expect(output).toContain("stop");
353
368
  expect(output).toContain("status");
354
369
  });
355
370
 
356
- test("--help includes --attach and --no-attach flags", async () => {
357
- const output = await captureStdout(() => coordinatorCommand(["--help"]));
371
+ test("start --help includes --attach and --no-attach flags", async () => {
372
+ const cmd = createCoordinatorCommand({});
373
+ for (const sub of cmd.commands) {
374
+ sub.exitOverride();
375
+ }
376
+ const output = await captureStdout(async () => {
377
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
378
+ });
358
379
  expect(output).toContain("--attach");
359
380
  expect(output).toContain("--no-attach");
360
381
  });
361
382
 
362
383
  test("-h outputs help text", async () => {
363
384
  const output = await captureStdout(() => coordinatorCommand(["-h"]));
364
- expect(output).toContain("overstory coordinator");
385
+ expect(output).toContain("coordinator");
365
386
  });
366
387
 
367
388
  test("empty args outputs help text", async () => {
368
389
  const output = await captureStdout(() => coordinatorCommand([]));
369
- expect(output).toContain("overstory coordinator");
370
- expect(output).toContain("Subcommands:");
390
+ expect(output).toContain("coordinator");
391
+ expect(output).toContain("Commands:");
371
392
  });
372
393
  });
373
394
 
@@ -385,7 +406,6 @@ describe("coordinatorCommand unknown subcommand", () => {
385
406
  const ve = err as ValidationError;
386
407
  expect(ve.message).toContain("frobnicate");
387
408
  expect(ve.field).toBe("subcommand");
388
- expect(ve.value).toBe("frobnicate");
389
409
  }
390
410
  });
391
411
  });
@@ -417,7 +437,7 @@ describe("startCoordinator", () => {
417
437
  expect(session?.pid).toBe(99999);
418
438
  expect(session?.parentAgent).toBeNull();
419
439
  expect(session?.depth).toBe(0);
420
- expect(session?.beadId).toBe("");
440
+ expect(session?.taskId).toBe("");
421
441
  expect(session?.branchName).toBe("main");
422
442
  expect(session?.worktreePath).toBe(tempDir);
423
443
  expect(session?.id).toMatch(/^session-\d+-coordinator$/);
@@ -630,6 +650,88 @@ describe("startCoordinator", () => {
630
650
  // The new session should have a different ID than the dead one
631
651
  expect(newSession?.id).not.toBe("session-dead-coordinator");
632
652
  });
653
+
654
+ test("throws AgentError when tmux is not available", async () => {
655
+ const { deps } = makeDeps({}, undefined, undefined, {
656
+ ensureTmuxAvailableError: new AgentError(
657
+ "tmux is not installed or not on PATH. Install tmux to use overstory agent orchestration.",
658
+ ),
659
+ });
660
+
661
+ await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
662
+ });
663
+
664
+ test("AgentError message mentions tmux not installed when tmux unavailable", async () => {
665
+ const { deps } = makeDeps({}, undefined, undefined, {
666
+ ensureTmuxAvailableError: new AgentError(
667
+ "tmux is not installed or not on PATH. Install tmux to use overstory agent orchestration.",
668
+ ),
669
+ });
670
+
671
+ try {
672
+ await coordinatorCommand(["start"], deps);
673
+ expect(true).toBe(false); // Should have thrown
674
+ } catch (err: unknown) {
675
+ expect(err).toBeInstanceOf(AgentError);
676
+ const agentErr = err as AgentError;
677
+ expect(agentErr.message).toContain("tmux is not installed");
678
+ }
679
+ });
680
+
681
+ test("throws AgentError when session dies during startup", async () => {
682
+ // waitForTuiReady returns false AND isSessionAlive returns false — session died
683
+ const { deps } = makeDeps(
684
+ { "overstory-test-project-coordinator": false },
685
+ undefined,
686
+ undefined,
687
+ { waitForTuiReadyResult: false },
688
+ );
689
+
690
+ await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
691
+ });
692
+
693
+ test("AgentError message mentions session dying when session dies during startup", async () => {
694
+ const { deps } = makeDeps(
695
+ { "overstory-test-project-coordinator": false },
696
+ undefined,
697
+ undefined,
698
+ { waitForTuiReadyResult: false },
699
+ );
700
+
701
+ try {
702
+ await coordinatorCommand(["start"], deps);
703
+ expect(true).toBe(false); // Should have thrown
704
+ } catch (err: unknown) {
705
+ expect(err).toBeInstanceOf(AgentError);
706
+ const agentErr = err as AgentError;
707
+ expect(agentErr.message).toContain("died during startup");
708
+ }
709
+ });
710
+
711
+ test("continues when waitForTuiReady times out but session is still alive", async () => {
712
+ // waitForTuiReady returns false (timeout) but session IS alive
713
+ const { deps } = makeDeps(
714
+ { "overstory-test-project-coordinator": true },
715
+ undefined,
716
+ undefined,
717
+ { waitForTuiReadyResult: false },
718
+ );
719
+
720
+ const originalSleep = Bun.sleep;
721
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
722
+
723
+ let thrownError: unknown;
724
+ try {
725
+ await captureStdout(() => coordinatorCommand(["start"], deps));
726
+ } catch (err: unknown) {
727
+ thrownError = err;
728
+ } finally {
729
+ Bun.sleep = originalSleep;
730
+ }
731
+
732
+ // Should NOT throw — session is alive, just slow TUI
733
+ expect(thrownError).toBeUndefined();
734
+ });
633
735
  });
634
736
 
635
737
  describe("stopCoordinator", () => {
@@ -1202,10 +1304,16 @@ describe("watchdog integration", () => {
1202
1304
  });
1203
1305
 
1204
1306
  describe("COORDINATOR_HELP", () => {
1205
- test("help text includes --watchdog flag", async () => {
1206
- const output = await captureStdout(() => coordinatorCommand(["--help"]));
1307
+ test("start help text includes --watchdog flag", async () => {
1308
+ const cmd = createCoordinatorCommand({});
1309
+ for (const sub of cmd.commands) {
1310
+ sub.exitOverride();
1311
+ }
1312
+ const output = await captureStdout(async () => {
1313
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
1314
+ });
1207
1315
  expect(output).toContain("--watchdog");
1208
- expect(output).toContain("Auto-start watchdog daemon with coordinator");
1316
+ expect(output).toContain("watchdog");
1209
1317
  });
1210
1318
  });
1211
1319
  });
@@ -1490,10 +1598,16 @@ describe("monitor integration", () => {
1490
1598
  });
1491
1599
 
1492
1600
  describe("COORDINATOR_HELP", () => {
1493
- test("help text includes --monitor flag", async () => {
1494
- const output = await captureStdout(() => coordinatorCommand(["--help"]));
1601
+ test("start help text includes --monitor flag", async () => {
1602
+ const cmd = createCoordinatorCommand({});
1603
+ for (const sub of cmd.commands) {
1604
+ sub.exitOverride();
1605
+ }
1606
+ const output = await captureStdout(async () => {
1607
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
1608
+ });
1495
1609
  expect(output).toContain("--monitor");
1496
- expect(output).toContain("Auto-start monitor agent (Tier 2) with coordinator");
1610
+ expect(output).toContain("monitor");
1497
1611
  });
1498
1612
  });
1499
1613
  });
@@ -1521,10 +1635,3 @@ describe("SessionStore round-trip", () => {
1521
1635
  expect(exists).toBe(true);
1522
1636
  });
1523
1637
  });
1524
-
1525
- describe("isRunningAsRoot (imported from sling)", () => {
1526
- test("is accessible from coordinator test file", () => {
1527
- expect(isRunningAsRoot(() => 0)).toBe(true);
1528
- expect(isRunningAsRoot(() => 1000)).toBe(false);
1529
- });
1530
- });
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { mkdir, unlink } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
+ import { Command } from "commander";
17
18
  import { deployHooks } from "../agents/hooks-deployer.ts";
18
19
  import { createIdentity, loadIdentity } from "../agents/identity.ts";
19
20
  import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
@@ -26,6 +27,7 @@ import type { AgentSession } from "../types.ts";
26
27
  import { isProcessRunning } from "../watchdog/health.ts";
27
28
  import {
28
29
  createSession,
30
+ ensureTmuxAvailable,
29
31
  isSessionAlive,
30
32
  killSession,
31
33
  sendKeys,
@@ -61,6 +63,7 @@ export interface CoordinatorDeps {
61
63
  timeoutMs?: number,
62
64
  pollIntervalMs?: number,
63
65
  ) => Promise<boolean>;
66
+ ensureTmuxAvailable: () => Promise<void>;
64
67
  };
65
68
  _watchdog?: {
66
69
  start: () => Promise<{ pid: number } | null>;
@@ -252,17 +255,6 @@ export function buildCoordinatorBeacon(cliName = "bd"): string {
252
255
  return parts.join(" — ");
253
256
  }
254
257
 
255
- /**
256
- * Start the coordinator agent.
257
- *
258
- * 1. Verify no coordinator is already running
259
- * 2. Load config
260
- * 3. Create agent identity (if first time)
261
- * 4. Deploy hooks to project root's .claude/settings.local.json
262
- * 5. Spawn tmux session at project root with Claude Code
263
- * 6. Send startup beacon
264
- * 7. Record session in SessionStore (sessions.db)
265
- */
266
258
  /**
267
259
  * Determine whether to auto-attach to the tmux session after starting.
268
260
  * Exported for testing.
@@ -273,19 +265,20 @@ export function resolveAttach(args: string[], isTTY: boolean): boolean {
273
265
  return isTTY;
274
266
  }
275
267
 
276
- async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
268
+ async function startCoordinator(
269
+ opts: { json: boolean; attach: boolean; watchdog: boolean; monitor: boolean },
270
+ deps: CoordinatorDeps = {},
271
+ ): Promise<void> {
277
272
  const tmux = deps._tmux ?? {
278
273
  createSession,
279
274
  isSessionAlive,
280
275
  killSession,
281
276
  sendKeys,
282
277
  waitForTuiReady,
278
+ ensureTmuxAvailable,
283
279
  };
284
280
 
285
- const json = args.includes("--json");
286
- const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
287
- const watchdogFlag = args.includes("--watchdog");
288
- const monitorFlag = args.includes("--monitor");
281
+ const { json, attach: shouldAttach, watchdog: watchdogFlag, monitor: monitorFlag } = opts;
289
282
 
290
283
  if (isRunningAsRoot()) {
291
284
  throw new AgentError(
@@ -354,6 +347,10 @@ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Pro
354
347
  const manifest = await manifestLoader.load();
355
348
  const { model, env } = resolveModel(config, manifest, "coordinator", "opus");
356
349
 
350
+ // Preflight: verify tmux is installed before attempting to spawn.
351
+ // Without this check, a missing tmux leads to cryptic errors later.
352
+ await tmux.ensureTmuxAvailable();
353
+
357
354
  // Spawn tmux session at project root with Claude Code (interactive mode).
358
355
  // Inject the coordinator base definition via --append-system-prompt so the
359
356
  // coordinator knows its role, hierarchy rules, and delegation patterns
@@ -382,7 +379,7 @@ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Pro
382
379
  capability: "coordinator",
383
380
  worktreePath: projectRoot, // Coordinator uses project root, not a worktree
384
381
  branchName: config.project.canonicalBranch, // Operates on canonical branch
385
- beadId: "", // No specific bead assignment
382
+ taskId: "", // No specific bead assignment
386
383
  tmuxSession,
387
384
  state: "booting",
388
385
  pid,
@@ -398,7 +395,20 @@ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Pro
398
395
  store.upsert(session);
399
396
 
400
397
  // Wait for Claude Code TUI to render before sending input
401
- await tmux.waitForTuiReady(tmuxSession);
398
+ const tuiReady = await tmux.waitForTuiReady(tmuxSession);
399
+ if (!tuiReady) {
400
+ // Session may have died — check liveness before proceeding
401
+ const alive = await tmux.isSessionAlive(tmuxSession);
402
+ if (!alive) {
403
+ // Clean up the stale session record
404
+ store.updateState(COORDINATOR_NAME, "completed");
405
+ throw new AgentError(
406
+ `Coordinator tmux session "${tmuxSession}" died during startup. The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.`,
407
+ { agentName: COORDINATOR_NAME },
408
+ );
409
+ }
410
+ // Session is alive but TUI didn't render in time — proceed with warning
411
+ }
402
412
  await Bun.sleep(1_000);
403
413
 
404
414
  const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
@@ -478,16 +488,17 @@ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Pro
478
488
  * 3. Mark session as completed in SessionStore
479
489
  * 4. Auto-complete the active run (if current-run.txt exists)
480
490
  */
481
- async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
491
+ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps = {}): Promise<void> {
482
492
  const tmux = deps._tmux ?? {
483
493
  createSession,
484
494
  isSessionAlive,
485
495
  killSession,
486
496
  sendKeys,
487
497
  waitForTuiReady,
498
+ ensureTmuxAvailable,
488
499
  };
489
500
 
490
- const json = args.includes("--json");
501
+ const { json } = opts;
491
502
  const cwd = process.cwd();
492
503
  const config = await loadConfig(cwd);
493
504
  const projectRoot = config.project.root;
@@ -584,16 +595,20 @@ async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Prom
584
595
  *
585
596
  * Checks session registry and tmux liveness to report actual state.
586
597
  */
587
- async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
598
+ async function statusCoordinator(
599
+ opts: { json: boolean },
600
+ deps: CoordinatorDeps = {},
601
+ ): Promise<void> {
588
602
  const tmux = deps._tmux ?? {
589
603
  createSession,
590
604
  isSessionAlive,
591
605
  killSession,
592
606
  sendKeys,
593
607
  waitForTuiReady,
608
+ ensureTmuxAvailable,
594
609
  };
595
610
 
596
- const json = args.includes("--json");
611
+ const { json } = opts;
597
612
  const cwd = process.cwd();
598
613
  const config = await loadConfig(cwd);
599
614
  const projectRoot = config.project.root;
@@ -670,31 +685,54 @@ async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Pr
670
685
  }
671
686
  }
672
687
 
673
- const COORDINATOR_HELP = `overstory coordinator — Manage the persistent coordinator agent
674
-
675
- Usage: overstory coordinator <subcommand> [flags]
676
-
677
- Subcommands:
678
- start Start the coordinator (spawns Claude Code at project root)
679
- stop Stop the coordinator (kills tmux session)
680
- status Show coordinator state
688
+ /**
689
+ * Create the Commander command for `overstory coordinator`.
690
+ */
691
+ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
692
+ const cmd = new Command("coordinator").description("Manage the persistent coordinator agent");
693
+
694
+ cmd
695
+ .command("start")
696
+ .description("Start the coordinator (spawns Claude Code at project root)")
697
+ .option("--attach", "Always attach to tmux session after start")
698
+ .option("--no-attach", "Never attach to tmux session after start")
699
+ .option("--watchdog", "Auto-start watchdog daemon with coordinator")
700
+ .option("--monitor", "Auto-start Tier 2 monitor agent with coordinator")
701
+ .option("--json", "Output as JSON")
702
+ .action(
703
+ async (opts: { attach?: boolean; watchdog?: boolean; monitor?: boolean; json?: boolean }) => {
704
+ // opts.attach = true if --attach, false if --no-attach, undefined if neither
705
+ const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
706
+ await startCoordinator(
707
+ {
708
+ json: opts.json ?? false,
709
+ attach: shouldAttach,
710
+ watchdog: opts.watchdog ?? false,
711
+ monitor: opts.monitor ?? false,
712
+ },
713
+ deps,
714
+ );
715
+ },
716
+ );
681
717
 
682
- Start options:
683
- --attach Always attach to tmux session after start
684
- --no-attach Never attach to tmux session after start
685
- Default: attach when running in an interactive TTY
686
- --watchdog Auto-start watchdog daemon with coordinator
687
- --monitor Auto-start monitor agent (Tier 2) with coordinator
718
+ cmd
719
+ .command("stop")
720
+ .description("Stop the coordinator (kills tmux session)")
721
+ .option("--json", "Output as JSON")
722
+ .action(async (opts: { json?: boolean }) => {
723
+ await stopCoordinator({ json: opts.json ?? false }, deps);
724
+ });
688
725
 
689
- General options:
690
- --json Output as JSON
691
- --help, -h Show this help
726
+ cmd
727
+ .command("status")
728
+ .description("Show coordinator state")
729
+ .option("--json", "Output as JSON")
730
+ .action(async (opts: { json?: boolean }) => {
731
+ await statusCoordinator({ json: opts.json ?? false }, deps);
732
+ });
692
733
 
693
- The coordinator runs at the project root and orchestrates work by:
694
- - Decomposing objectives into beads issues
695
- - Dispatching agents via overstory sling
696
- - Tracking batches via task groups
697
- - Handling escalations from agents and watchdog`;
734
+ return cmd;
735
+ }
698
736
 
699
737
  /**
700
738
  * Entry point for `overstory coordinator <subcommand>`.
@@ -706,28 +744,27 @@ export async function coordinatorCommand(
706
744
  args: string[],
707
745
  deps: CoordinatorDeps = {},
708
746
  ): Promise<void> {
709
- if (args.includes("--help") || args.includes("-h") || args.length === 0) {
710
- process.stdout.write(`${COORDINATOR_HELP}\n`);
747
+ const cmd = createCoordinatorCommand(deps);
748
+ cmd.exitOverride();
749
+
750
+ if (args.length === 0) {
751
+ process.stdout.write(cmd.helpInformation());
711
752
  return;
712
753
  }
713
754
 
714
- const subcommand = args[0];
715
- const subArgs = args.slice(1);
716
-
717
- switch (subcommand) {
718
- case "start":
719
- await startCoordinator(subArgs, deps);
720
- break;
721
- case "stop":
722
- await stopCoordinator(subArgs, deps);
723
- break;
724
- case "status":
725
- await statusCoordinator(subArgs, deps);
726
- break;
727
- default:
728
- throw new ValidationError(
729
- `Unknown coordinator subcommand: ${subcommand}. Run 'overstory coordinator --help' for usage.`,
730
- { field: "subcommand", value: subcommand },
731
- );
755
+ try {
756
+ await cmd.parseAsync(args, { from: "user" });
757
+ } catch (err: unknown) {
758
+ if (err && typeof err === "object" && "code" in err) {
759
+ const code = (err as { code: string }).code;
760
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
761
+ return;
762
+ }
763
+ if (code === "commander.unknownCommand") {
764
+ const message = err instanceof Error ? err.message : String(err);
765
+ throw new ValidationError(message, { field: "subcommand" });
766
+ }
767
+ }
768
+ throw err;
732
769
  }
733
770
  }