@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
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Tests for the coordinator action wrappers consumed by the REST API.
3
+ *
4
+ * Uses real SQLite stores (file-backed temp dirs) and the live module-level
5
+ * connection registry. The live registry is reset between tests via
6
+ * removeConnection() in afterEach to avoid cross-test leakage.
7
+ *
8
+ * mock.module() is intentionally avoided per mulch record mx-56558b.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { AgentError } from "../../errors.ts";
16
+ import type { MailStore } from "../../mail/store.ts";
17
+ import { createMailStore } from "../../mail/store.ts";
18
+ import { getConnection, removeConnection, setConnection } from "../../runtimes/connections.ts";
19
+ import { HeadlessClaudeConnection } from "../../runtimes/headless-connection.ts";
20
+ import type { SessionStore } from "../../sessions/store.ts";
21
+ import { createSessionStore } from "../../sessions/store.ts";
22
+ import type { AgentSession } from "../../types.ts";
23
+ import {
24
+ askCoordinatorAction,
25
+ ConflictError,
26
+ type CoordinatorActionDeps,
27
+ getCoordinatorState,
28
+ sendToCoordinator,
29
+ startCoordinatorHeadless,
30
+ stopCoordinator,
31
+ } from "./coordinator-actions.ts";
32
+
33
+ const COORDINATOR = "coordinator";
34
+
35
+ interface Ctx {
36
+ tempDir: string;
37
+ sessionStore: SessionStore;
38
+ mailStore: MailStore;
39
+ deps: CoordinatorActionDeps;
40
+ }
41
+
42
+ function makeCoordinatorSession(overrides: Partial<AgentSession> = {}): AgentSession {
43
+ const now = new Date().toISOString();
44
+ return {
45
+ id: `session-${Date.now()}-${COORDINATOR}`,
46
+ agentName: COORDINATOR,
47
+ capability: "coordinator",
48
+ worktreePath: "/tmp/wt",
49
+ branchName: "main",
50
+ taskId: "",
51
+ tmuxSession: "overstory-test-coordinator",
52
+ state: "working",
53
+ pid: 99999,
54
+ parentAgent: null,
55
+ depth: 0,
56
+ runId: `run-${Date.now()}`,
57
+ startedAt: now,
58
+ lastActivity: now,
59
+ escalationLevel: 0,
60
+ stalledSince: null,
61
+ transcriptPath: null,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ function makeCtx(): Ctx {
67
+ const tempDir = mkdtempSync(join(tmpdir(), "ovs-coord-actions-"));
68
+ const sessionStore = createSessionStore(join(tempDir, "sessions.db"));
69
+ const mailStore = createMailStore(join(tempDir, "mail.db"));
70
+ return {
71
+ tempDir,
72
+ sessionStore,
73
+ mailStore,
74
+ deps: {
75
+ projectRoot: tempDir,
76
+ _sessionStore: sessionStore,
77
+ _mailStore: mailStore,
78
+ },
79
+ };
80
+ }
81
+
82
+ describe("coordinator-actions", () => {
83
+ let ctx: Ctx;
84
+
85
+ beforeEach(() => {
86
+ ctx = makeCtx();
87
+ });
88
+
89
+ afterEach(() => {
90
+ // Always clean up the live connection registry for "coordinator".
91
+ if (getConnection(COORDINATOR) !== undefined) {
92
+ removeConnection(COORDINATOR);
93
+ }
94
+ ctx.sessionStore.close();
95
+ ctx.mailStore.close();
96
+ rmSync(ctx.tempDir, { recursive: true, force: true });
97
+ });
98
+
99
+ // ─── getCoordinatorState ──────────────────────────────────────────────────
100
+
101
+ describe("getCoordinatorState", () => {
102
+ test("returns running: false when no session exists", () => {
103
+ const state = getCoordinatorState(ctx.deps);
104
+ expect(state.running).toBe(false);
105
+ expect(state.agentName).toBe(COORDINATOR);
106
+ expect(state.pid).toBeNull();
107
+ expect(state.tmuxSession).toBeNull();
108
+ expect(state.runId).toBeNull();
109
+ expect(state.headless).toBe(false);
110
+ });
111
+
112
+ test("returns running: false for completed sessions", () => {
113
+ ctx.sessionStore.upsert(makeCoordinatorSession({ state: "completed" }));
114
+ expect(getCoordinatorState(ctx.deps).running).toBe(false);
115
+ });
116
+
117
+ test("returns running: true with live session fields", () => {
118
+ const session = makeCoordinatorSession({ state: "working" });
119
+ ctx.sessionStore.upsert(session);
120
+ const state = getCoordinatorState(ctx.deps);
121
+ expect(state.running).toBe(true);
122
+ expect(state.pid).toBe(session.pid);
123
+ expect(state.tmuxSession).toBe(session.tmuxSession);
124
+ expect(state.runId).toBe(session.runId);
125
+ expect(state.startedAt).toBe(session.startedAt);
126
+ expect(state.headless).toBe(false);
127
+ });
128
+
129
+ test("normalizes empty tmuxSession to null", () => {
130
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "" }));
131
+ expect(getCoordinatorState(ctx.deps).tmuxSession).toBeNull();
132
+ });
133
+
134
+ test("headless: true when a HeadlessClaudeConnection is registered", () => {
135
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "" }));
136
+ // Use a stub stdin — no real subprocess required for state inspection
137
+ const conn = new HeadlessClaudeConnection(99999, {
138
+ write: () => 0,
139
+ });
140
+ setConnection(COORDINATOR, conn);
141
+ expect(getCoordinatorState(ctx.deps).headless).toBe(true);
142
+ });
143
+ });
144
+
145
+ // ─── sendToCoordinator ────────────────────────────────────────────────────
146
+
147
+ describe("sendToCoordinator", () => {
148
+ test("throws AgentError when no active session", async () => {
149
+ await expect(sendToCoordinator(ctx.deps, "hi", { subject: "test" })).rejects.toBeInstanceOf(
150
+ AgentError,
151
+ );
152
+ });
153
+
154
+ test("throws ConflictError for tmux session without registered connection", async () => {
155
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "tmux-pane" }));
156
+ await expect(sendToCoordinator(ctx.deps, "hi", { subject: "test" })).rejects.toBeInstanceOf(
157
+ ConflictError,
158
+ );
159
+ });
160
+
161
+ test("writes mail row and returns messageId for headless session", async () => {
162
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "" }));
163
+ const result = await sendToCoordinator(ctx.deps, "hello body", {
164
+ subject: "operator dispatch",
165
+ from: "operator",
166
+ });
167
+ expect(result.messageId).toMatch(/^msg-/);
168
+
169
+ const rows = ctx.mailStore.getAll({ to: COORDINATOR });
170
+ expect(rows.length).toBe(1);
171
+ const m = rows[0];
172
+ expect(m).toBeDefined();
173
+ expect(m?.subject).toBe("operator dispatch");
174
+ expect(m?.body).toBe("hello body");
175
+ expect(m?.type).toBe("dispatch");
176
+ expect(m?.from).toBe("operator");
177
+ });
178
+
179
+ test("defaults from to 'operator' when not provided", async () => {
180
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "" }));
181
+ await sendToCoordinator(ctx.deps, "x", { subject: "y" });
182
+ const rows = ctx.mailStore.getAll({ to: COORDINATOR });
183
+ expect(rows[0]?.from).toBe("operator");
184
+ });
185
+
186
+ test("succeeds for tmux session WITH a registered connection", async () => {
187
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "tmux-pane" }));
188
+ const conn = new HeadlessClaudeConnection(99999, { write: () => 0 });
189
+ setConnection(COORDINATOR, conn);
190
+ const result = await sendToCoordinator(ctx.deps, "hi", { subject: "s" });
191
+ expect(result.messageId).toMatch(/^msg-/);
192
+ });
193
+ });
194
+
195
+ // ─── askCoordinatorAction ─────────────────────────────────────────────────
196
+
197
+ describe("askCoordinatorAction", () => {
198
+ test("throws AgentError when no active session", async () => {
199
+ await expect(
200
+ askCoordinatorAction(ctx.deps, "x", { subject: "y", timeoutSec: 1 }),
201
+ ).rejects.toBeInstanceOf(AgentError);
202
+ });
203
+
204
+ test("returns reply when one is inserted into mail.db before deadline", async () => {
205
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "" }));
206
+ const fastDeps: CoordinatorActionDeps = { ...ctx.deps, _askPollIntervalMs: 20 };
207
+
208
+ // Schedule a reply via setTimeout so the polling loop can observe it.
209
+ const askPromise = askCoordinatorAction(fastDeps, "question?", {
210
+ subject: "Q",
211
+ from: "operator",
212
+ timeoutSec: 5,
213
+ });
214
+
215
+ // Wait briefly for the request mail to land, then write a reply in the
216
+ // same thread from coordinator -> operator.
217
+ setTimeout(() => {
218
+ const sent = ctx.mailStore.getAll({ to: COORDINATOR });
219
+ const original = sent[0];
220
+ if (!original) return;
221
+ ctx.mailStore.insert({
222
+ id: "",
223
+ from: COORDINATOR,
224
+ to: "operator",
225
+ subject: "Re: Q",
226
+ body: "the answer",
227
+ type: "dispatch" as const,
228
+ priority: "normal" as const,
229
+ threadId: original.id,
230
+ payload: null,
231
+ });
232
+ }, 50);
233
+
234
+ const result = await askPromise;
235
+ expect(result.timedOut).toBe(false);
236
+ expect(result.reply?.body).toBe("the answer");
237
+ expect(result.messageId).toMatch(/^msg-/);
238
+ });
239
+
240
+ test("returns timedOut: true when no reply arrives", async () => {
241
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "" }));
242
+ const fastDeps: CoordinatorActionDeps = { ...ctx.deps, _askPollIntervalMs: 30 };
243
+
244
+ const result = await askCoordinatorAction(fastDeps, "?", {
245
+ subject: "Q",
246
+ from: "operator",
247
+ timeoutSec: 1,
248
+ });
249
+ expect(result.timedOut).toBe(true);
250
+ expect(result.reply).toBeNull();
251
+ expect(result.messageId).toMatch(/^msg-/);
252
+ });
253
+
254
+ test("throws ConflictError for tmux session without connection", async () => {
255
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "tmux-pane" }));
256
+ await expect(
257
+ askCoordinatorAction(ctx.deps, "?", { subject: "Q", timeoutSec: 1 }),
258
+ ).rejects.toBeInstanceOf(ConflictError);
259
+ });
260
+ });
261
+
262
+ // ─── startCoordinatorHeadless ─────────────────────────────────────────────
263
+
264
+ describe("startCoordinatorHeadless", () => {
265
+ test("returns alreadyRunning: true when session already exists", async () => {
266
+ const existing = makeCoordinatorSession({ state: "working", tmuxSession: "" });
267
+ ctx.sessionStore.upsert(existing);
268
+ const result = await startCoordinatorHeadless(ctx.deps);
269
+ expect(result.alreadyRunning).toBe(true);
270
+ expect(result.started).toBe(false);
271
+ expect(result.pid).toBe(existing.pid);
272
+ expect(result.runId).toBe(existing.runId);
273
+ });
274
+
275
+ test("calls injected start function and reports started: true", async () => {
276
+ let calledWithHeadless: boolean | undefined;
277
+ const fakeStart = (async (opts: { headless?: boolean }) => {
278
+ calledWithHeadless = opts.headless;
279
+ ctx.sessionStore.upsert(
280
+ makeCoordinatorSession({ tmuxSession: "", state: "booting", pid: 12345 }),
281
+ );
282
+ }) as unknown as CoordinatorActionDeps["_startCoordinatorSession"];
283
+
284
+ const result = await startCoordinatorHeadless({
285
+ ...ctx.deps,
286
+ _startCoordinatorSession: fakeStart,
287
+ });
288
+ expect(calledWithHeadless).toBe(true);
289
+ expect(result.started).toBe(true);
290
+ expect(result.alreadyRunning).toBe(false);
291
+ expect(result.pid).toBe(12345);
292
+ });
293
+
294
+ test("translates 'already running' AgentError race into alreadyRunning result", async () => {
295
+ const fakeStart = (async () => {
296
+ // Simulate another caller racing in
297
+ ctx.sessionStore.upsert(
298
+ makeCoordinatorSession({ tmuxSession: "", state: "working", pid: 7777 }),
299
+ );
300
+ throw new AgentError("Coordinator is already running (tmux: x, since: y)", {
301
+ agentName: COORDINATOR,
302
+ });
303
+ }) as unknown as CoordinatorActionDeps["_startCoordinatorSession"];
304
+
305
+ const result = await startCoordinatorHeadless({
306
+ ...ctx.deps,
307
+ _startCoordinatorSession: fakeStart,
308
+ });
309
+ expect(result.alreadyRunning).toBe(true);
310
+ expect(result.started).toBe(false);
311
+ expect(result.pid).toBe(7777);
312
+ });
313
+ });
314
+
315
+ // ─── stopCoordinator ──────────────────────────────────────────────────────
316
+
317
+ describe("stopCoordinator", () => {
318
+ test("returns stopped: false when no active session", async () => {
319
+ const result = await stopCoordinator(ctx.deps);
320
+ expect(result.stopped).toBe(false);
321
+ });
322
+
323
+ test("calls injected stop function and reports stopped: true", async () => {
324
+ ctx.sessionStore.upsert(makeCoordinatorSession({ tmuxSession: "" }));
325
+ let stopCalled = false;
326
+ const fakeStop = (async () => {
327
+ stopCalled = true;
328
+ ctx.sessionStore.updateState(COORDINATOR, "completed");
329
+ }) as unknown as CoordinatorActionDeps["_stopCoordinatorSession"];
330
+
331
+ const result = await stopCoordinator({
332
+ ...ctx.deps,
333
+ _stopCoordinatorSession: fakeStop,
334
+ });
335
+ expect(stopCalled).toBe(true);
336
+ expect(result.stopped).toBe(true);
337
+ });
338
+ });
339
+ });