@os-eco/overstory-cli 0.9.4 → 0.11.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.
Files changed (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +219 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -166,11 +166,20 @@ export async function discoverAgents(
166
166
 
167
167
  /**
168
168
  * Format the state icon for display.
169
+ *
170
+ * `in_turn` and `between_turns` (overstory-3087) render with the same cyan
171
+ * accent as `working` so a spawn-per-turn worker is visually grouped with
172
+ * other healthy/active agents in `ov agents` output. They use distinct
173
+ * glyphs ('>' vs '~') to mirror the dashboard / theme.ts mapping.
169
174
  */
170
175
  function getStateIcon(state: string): string {
171
176
  switch (state) {
172
177
  case "working":
173
178
  return color.cyan(">");
179
+ case "in_turn":
180
+ return color.cyan(">");
181
+ case "between_turns":
182
+ return color.cyan("~");
174
183
  case "booting":
175
184
  return color.green("-");
176
185
  case "stalled":
@@ -124,6 +124,7 @@ async function logSyntheticSessionEndEvents(overstoryDir: string): Promise<numbe
124
124
  interface CleanResult {
125
125
  sessionEndEventsLogged: number;
126
126
  tmuxKilled: number;
127
+ orphanPidsReaped: number;
127
128
  worktreesCleaned: number;
128
129
  branchesDeleted: number;
129
130
  mailWiped: boolean;
@@ -217,6 +218,45 @@ function loadRegisteredTmuxNames(overstoryDir: string): Set<string> | null {
217
218
  }
218
219
  }
219
220
 
221
+ /**
222
+ * Reap any spawn PIDs in sessions.db that survived tmux teardown.
223
+ *
224
+ * `killAllTmuxSessions` walks descendants of the live tmux pane PID and is
225
+ * sufficient for sessions whose tmux container is still up. This handles the
226
+ * leftover case: a stored pid that is still alive but its tmux session is
227
+ * gone (claude was reparented to init when its bash wrapper got SIGHUP) or
228
+ * the session is in a terminal state but the spawn never exited. Best-effort.
229
+ * (overstory-505d)
230
+ */
231
+ async function reapOrphanedPids(overstoryDir: string): Promise<number> {
232
+ let reaped = 0;
233
+ try {
234
+ const dbPath = join(overstoryDir, "sessions.db");
235
+ const jsonPath = join(overstoryDir, "sessions.json");
236
+ if (!existsSync(dbPath) && !existsSync(jsonPath)) {
237
+ return 0;
238
+ }
239
+ const { store } = openSessionStore(overstoryDir);
240
+ try {
241
+ for (const session of store.getAll()) {
242
+ if (session.pid === null) continue;
243
+ if (!isProcessAlive(session.pid)) continue;
244
+ try {
245
+ await killProcessTree(session.pid);
246
+ reaped++;
247
+ } catch {
248
+ // Best effort
249
+ }
250
+ }
251
+ } finally {
252
+ store.close();
253
+ }
254
+ } catch {
255
+ // Best effort
256
+ }
257
+ return reaped;
258
+ }
259
+
220
260
  /**
221
261
  * Remove all overstory worktrees (force remove with branch deletion).
222
262
  */
@@ -568,6 +608,7 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
568
608
  const result: CleanResult = {
569
609
  sessionEndEventsLogged: 0,
570
610
  tmuxKilled: 0,
611
+ orphanPidsReaped: 0,
571
612
  worktreesCleaned: 0,
572
613
  branchesDeleted: 0,
573
614
  mailWiped: false,
@@ -609,6 +650,14 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
609
650
  result.tmuxKilled = await killAllTmuxSessions(overstoryDir, config.project.name);
610
651
  }
611
652
 
653
+ // 2b. Reap any orphaned spawn PIDs that survived tmux teardown.
654
+ // Must run after killAllTmuxSessions (which collects descendants of live
655
+ // panes) but before sessions.db is wiped (we need pid records to find
656
+ // orphans). (overstory-505d)
657
+ if (doWorktrees || all) {
658
+ result.orphanPidsReaped = await reapOrphanedPids(overstoryDir);
659
+ }
660
+
612
661
  // 3. Remove worktrees
613
662
  if (doWorktrees) {
614
663
  result.worktreesCleaned = await cleanAllWorktrees(root);
@@ -670,6 +719,11 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
670
719
  if (result.tmuxKilled > 0) {
671
720
  lines.push(`Killed ${result.tmuxKilled} tmux session${result.tmuxKilled === 1 ? "" : "s"}`);
672
721
  }
722
+ if (result.orphanPidsReaped > 0) {
723
+ lines.push(
724
+ `Reaped ${result.orphanPidsReaped} orphaned spawn process${result.orphanPidsReaped === 1 ? "" : "es"}`,
725
+ );
726
+ }
673
727
  if (result.worktreesCleaned > 0) {
674
728
  lines.push(
675
729
  `Removed ${result.worktreesCleaned} worktree${result.worktreesCleaned === 1 ? "" : "s"}`,
@@ -28,6 +28,7 @@ import {
28
28
  coordinatorCommand,
29
29
  createCoordinatorCommand,
30
30
  resolveAttach,
31
+ startCoordinatorSession,
31
32
  } from "./coordinator.ts";
32
33
  import {
33
34
  buildOrchestratorBeacon,
@@ -1600,6 +1601,133 @@ describe("watchdog integration", () => {
1600
1601
  expect(output).toContain("--watchdog");
1601
1602
  expect(output).toContain("watchdog");
1602
1603
  });
1604
+
1605
+ test("start help text includes --accept-existing-watchdog flag", async () => {
1606
+ const cmd = createCoordinatorCommand({});
1607
+ for (const sub of cmd.commands) {
1608
+ sub.exitOverride();
1609
+ }
1610
+ const output = await captureStdout(async () => {
1611
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
1612
+ });
1613
+ expect(output).toContain("--accept-existing-watchdog");
1614
+ });
1615
+ });
1616
+
1617
+ // overstory-3f0c: detect leftover watchdog from a previous session before
1618
+ // spawning, so operators do not get unexpected watchdog supervision.
1619
+ describe("orphan watchdog detection (overstory-3f0c)", () => {
1620
+ // (a) start (no --watchdog) + isRunning=true -> throws AgentError with PID
1621
+ // and mention of --accept-existing-watchdog in the message
1622
+ test("rejects start with AgentError when no flag passed and watchdog already running", async () => {
1623
+ const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
1624
+ const originalSleep = Bun.sleep;
1625
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1626
+
1627
+ try {
1628
+ await coordinatorCommand(["start", "--json"], deps);
1629
+ expect.unreachable("should have thrown AgentError");
1630
+ } catch (err) {
1631
+ expect(err).toBeInstanceOf(AgentError);
1632
+ const ae = err as AgentError;
1633
+ expect(ae.message).toContain("Watchdog daemon");
1634
+ // PID is unavailable from the fake watchdog (no PID file written),
1635
+ // so the message reports "unknown PID" — but it must reference the
1636
+ // concept and the suppress flag explicitly.
1637
+ expect(ae.message).toMatch(/PID/);
1638
+ expect(ae.message).toContain("--accept-existing-watchdog");
1639
+ expect(ae.message).toContain("--watchdog");
1640
+ expect(ae.message).toContain("ov watch --kill-others");
1641
+ } finally {
1642
+ Bun.sleep = originalSleep;
1643
+ }
1644
+
1645
+ // Detection ran but auto-start did NOT — the throw fired first.
1646
+ expect(watchdogCalls?.isRunning).toBeGreaterThanOrEqual(1);
1647
+ expect(watchdogCalls?.start).toBe(0);
1648
+ });
1649
+
1650
+ // (b) start --watchdog + isRunning=true -> does NOT throw;
1651
+ // watchdog.start() is still called once
1652
+ test("--watchdog with already-running daemon does NOT throw and still calls start()", async () => {
1653
+ const { deps, watchdogCalls } = makeDeps(
1654
+ {},
1655
+ { running: true, startSuccess: false }, // startSuccess:false simulates the no-op-when-already-running return
1656
+ );
1657
+ const originalSleep = Bun.sleep;
1658
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1659
+
1660
+ let output: string;
1661
+ try {
1662
+ output = await captureStdout(() =>
1663
+ coordinatorCommand(["start", "--watchdog", "--json"], deps),
1664
+ );
1665
+ } finally {
1666
+ Bun.sleep = originalSleep;
1667
+ }
1668
+
1669
+ expect(watchdogCalls?.start).toBe(1);
1670
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1671
+ // reused-daemon sentinel keeps watchdog truthy in the JSON output
1672
+ expect(parsed.watchdog).toBe(true);
1673
+ expect(parsed.watchdogPreexisting).toBe(true);
1674
+ });
1675
+
1676
+ // (c) start --accept-existing-watchdog + isRunning=true -> does NOT throw;
1677
+ // coordinator starts normally; watchdog.start() is NOT called
1678
+ test("--accept-existing-watchdog allows start without calling watchdog.start()", async () => {
1679
+ const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
1680
+ const originalSleep = Bun.sleep;
1681
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1682
+
1683
+ let output: string;
1684
+ try {
1685
+ output = await captureStdout(() =>
1686
+ coordinatorCommand(["start", "--accept-existing-watchdog", "--json"], deps),
1687
+ );
1688
+ } finally {
1689
+ Bun.sleep = originalSleep;
1690
+ }
1691
+
1692
+ expect(watchdogCalls?.start).toBe(0);
1693
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1694
+ expect(parsed.watchdog).toBe(true);
1695
+ expect(parsed.watchdogPreexisting).toBe(true);
1696
+ });
1697
+
1698
+ // (d) start (no --watchdog) + isRunning=false -> no error, no start
1699
+ // (regression — preserves the original "no flag, no daemon activity" path)
1700
+ test("no flag + watchdog not running: starts normally without calling start()", async () => {
1701
+ const { deps, watchdogCalls } = makeDeps({}, { running: false, startSuccess: true });
1702
+ const originalSleep = Bun.sleep;
1703
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1704
+
1705
+ let output: string;
1706
+ try {
1707
+ output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1708
+ } finally {
1709
+ Bun.sleep = originalSleep;
1710
+ }
1711
+
1712
+ expect(watchdogCalls?.start).toBe(0);
1713
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1714
+ expect(parsed.watchdog).toBe(false);
1715
+ expect(parsed.watchdogPreexisting).toBe(false);
1716
+ });
1717
+
1718
+ test("orchestrator inherits the same orphan-watchdog detection", async () => {
1719
+ const { deps, watchdogCalls } = makeDeps({}, { running: true });
1720
+ const originalSleep = Bun.sleep;
1721
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1722
+
1723
+ try {
1724
+ await expect(orchestratorCommand(["start", "--json"], deps)).rejects.toThrow(AgentError);
1725
+ } finally {
1726
+ Bun.sleep = originalSleep;
1727
+ }
1728
+
1729
+ expect(watchdogCalls?.start).toBe(0);
1730
+ });
1603
1731
  });
1604
1732
  });
1605
1733
 
@@ -2665,3 +2793,129 @@ describe("checkComplete", () => {
2665
2793
  expect(subcommandNames).toContain("check-complete");
2666
2794
  });
2667
2795
  });
2796
+
2797
+ describe("startCoordinatorSession headless", () => {
2798
+ test("with headless: true, calls spawnHeadlessAgent and skips tmux", async () => {
2799
+ const { tmux, calls: tmuxCalls } = makeFakeTmux();
2800
+ const { watchdog } = makeFakeWatchdog();
2801
+ const { monitor } = makeFakeMonitor();
2802
+
2803
+ const spawnCalls: Array<{
2804
+ argv: string[];
2805
+ cwd: string;
2806
+ agentName?: string;
2807
+ }> = [];
2808
+ const writes: string[] = [];
2809
+
2810
+ const fakeSpawn = async (
2811
+ argv: string[],
2812
+ opts: { cwd: string; env: Record<string, string>; agentName?: string },
2813
+ ): Promise<{
2814
+ pid: number;
2815
+ stdin: { write(data: string | Uint8Array): number | Promise<number> };
2816
+ stdout: ReadableStream<Uint8Array> | null;
2817
+ }> => {
2818
+ spawnCalls.push({ argv, cwd: opts.cwd, agentName: opts.agentName });
2819
+ return {
2820
+ pid: 55555,
2821
+ stdin: {
2822
+ write(data: string | Uint8Array): number {
2823
+ writes.push(typeof data === "string" ? data : new TextDecoder().decode(data));
2824
+ return 0;
2825
+ },
2826
+ },
2827
+ stdout: null,
2828
+ };
2829
+ };
2830
+
2831
+ const deps: CoordinatorDeps = {
2832
+ _tmux: tmux,
2833
+ _watchdog: watchdog,
2834
+ _monitor: monitor,
2835
+ _spawnHeadless: fakeSpawn,
2836
+ };
2837
+
2838
+ await captureStdout(async () => {
2839
+ await startCoordinatorSession(
2840
+ {
2841
+ json: true,
2842
+ attach: false,
2843
+ watchdog: false,
2844
+ monitor: false,
2845
+ headless: true,
2846
+ },
2847
+ deps,
2848
+ );
2849
+ });
2850
+
2851
+ // spawnHeadlessAgent was called exactly once with agentName: "coordinator"
2852
+ expect(spawnCalls.length).toBe(1);
2853
+ expect(spawnCalls[0]?.agentName).toBe("coordinator");
2854
+ expect(spawnCalls[0]?.cwd).toBe(tempDir);
2855
+
2856
+ // initial stdin prompt was written
2857
+ expect(writes.length).toBeGreaterThanOrEqual(1);
2858
+
2859
+ // tmux helpers were never called for the headless path
2860
+ expect(tmuxCalls.createSession.length).toBe(0);
2861
+ expect(tmuxCalls.sendKeys.length).toBe(0);
2862
+ expect(tmuxCalls.waitForTuiReady.length).toBe(0);
2863
+ expect(tmuxCalls.ensureTmuxAvailable).toBe(0);
2864
+
2865
+ // Session row records empty tmuxSession + the headless spawn pid
2866
+ const sessions = loadSessionsFromDb();
2867
+ expect(sessions.length).toBe(1);
2868
+ expect(sessions[0]?.agentName).toBe("coordinator");
2869
+ expect(sessions[0]?.tmuxSession).toBe("");
2870
+ expect(sessions[0]?.pid).toBe(55555);
2871
+ expect(sessions[0]?.state).toBe("booting");
2872
+
2873
+ // current-run.txt was written for downstream consumers
2874
+ const runFile = Bun.file(join(overstoryDir, "current-run.txt"));
2875
+ expect(await runFile.exists()).toBe(true);
2876
+ });
2877
+
2878
+ test("rejects when runtime has no buildDirectSpawn", async () => {
2879
+ // Override config to route the coordinator capability to a runtime that
2880
+ // lacks buildDirectSpawn (e.g. cursor). The headless path must reject.
2881
+ await Bun.write(
2882
+ join(overstoryDir, "config.yaml"),
2883
+ [
2884
+ "project:",
2885
+ " name: test-project",
2886
+ ` root: ${tempDir}`,
2887
+ " canonicalBranch: main",
2888
+ "watchdog:",
2889
+ " tier2Enabled: true",
2890
+ "runtime:",
2891
+ " capabilities:",
2892
+ " coordinator: cursor",
2893
+ ].join("\n"),
2894
+ );
2895
+
2896
+ const { tmux } = makeFakeTmux();
2897
+ const { watchdog } = makeFakeWatchdog();
2898
+ const { monitor } = makeFakeMonitor();
2899
+ const deps: CoordinatorDeps = {
2900
+ _tmux: tmux,
2901
+ _watchdog: watchdog,
2902
+ _monitor: monitor,
2903
+ _spawnHeadless: async () => {
2904
+ throw new Error("should not be called");
2905
+ },
2906
+ };
2907
+
2908
+ await expect(
2909
+ startCoordinatorSession(
2910
+ {
2911
+ json: true,
2912
+ attach: false,
2913
+ watchdog: false,
2914
+ monitor: false,
2915
+ headless: true,
2916
+ },
2917
+ deps,
2918
+ ),
2919
+ ).rejects.toThrow(ValidationError);
2920
+ });
2921
+ });