@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Tests for overstory stop command.
3
+ *
4
+ * Uses real temp directories and real git repos for file I/O and config loading.
5
+ * Tmux and worktree operations are injected via the StopDeps DI interface instead of
6
+ * mock.module() to avoid the process-global mock leak issue
7
+ * (see mulch record mx-56558b).
8
+ *
9
+ * WHY DI instead of mock.module: mock.module() in bun:test is process-global
10
+ * and leaks across test files. The DI approach (same pattern as coordinator.ts)
11
+ * ensures mocks are scoped to each test invocation.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdir, realpath } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { AgentError, ValidationError } from "../errors.ts";
18
+ import { openSessionStore } from "../sessions/compat.ts";
19
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
20
+ import type { AgentSession } from "../types.ts";
21
+ import { type StopDeps, stopCommand } from "./stop.ts";
22
+
23
+ // --- Fake Tmux ---
24
+
25
+ /** Track calls to fake tmux for assertions. */
26
+ interface TmuxCallTracker {
27
+ isSessionAlive: Array<{ name: string; result: boolean }>;
28
+ killSession: Array<{ name: string }>;
29
+ }
30
+
31
+ /** Build a fake tmux DI object with configurable session liveness. */
32
+ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
33
+ tmux: NonNullable<StopDeps["_tmux"]>;
34
+ calls: TmuxCallTracker;
35
+ } {
36
+ const calls: TmuxCallTracker = {
37
+ isSessionAlive: [],
38
+ killSession: [],
39
+ };
40
+
41
+ const tmux: NonNullable<StopDeps["_tmux"]> = {
42
+ isSessionAlive: async (name: string): Promise<boolean> => {
43
+ const alive = sessionAliveMap[name] ?? false;
44
+ calls.isSessionAlive.push({ name, result: alive });
45
+ return alive;
46
+ },
47
+ killSession: async (name: string): Promise<void> => {
48
+ calls.killSession.push({ name });
49
+ },
50
+ };
51
+
52
+ return { tmux, calls };
53
+ }
54
+
55
+ // --- Fake Worktree ---
56
+
57
+ /** Track calls to fake worktree for assertions. */
58
+ interface WorktreeCallTracker {
59
+ remove: Array<{
60
+ repoRoot: string;
61
+ path: string;
62
+ options?: { force?: boolean; forceBranch?: boolean };
63
+ }>;
64
+ }
65
+
66
+ /** Build a fake worktree DI object with configurable success/failure. */
67
+ function makeFakeWorktree(shouldFail = false): {
68
+ worktree: NonNullable<StopDeps["_worktree"]>;
69
+ calls: WorktreeCallTracker;
70
+ } {
71
+ const calls: WorktreeCallTracker = { remove: [] };
72
+
73
+ const worktree: NonNullable<StopDeps["_worktree"]> = {
74
+ remove: async (
75
+ repoRoot: string,
76
+ path: string,
77
+ options?: { force?: boolean; forceBranch?: boolean },
78
+ ): Promise<void> => {
79
+ calls.remove.push({ repoRoot, path, options });
80
+ if (shouldFail) {
81
+ throw new Error("worktree removal failed");
82
+ }
83
+ },
84
+ };
85
+
86
+ return { worktree, calls };
87
+ }
88
+
89
+ // --- Test Setup ---
90
+
91
+ let tempDir: string;
92
+ let overstoryDir: string;
93
+ const originalCwd = process.cwd();
94
+
95
+ /** Save sessions to the SessionStore (sessions.db) for test setup. */
96
+ function saveSessionsToDb(sessions: AgentSession[]): void {
97
+ const { store } = openSessionStore(overstoryDir);
98
+ try {
99
+ for (const session of sessions) {
100
+ store.upsert(session);
101
+ }
102
+ } finally {
103
+ store.close();
104
+ }
105
+ }
106
+
107
+ beforeEach(async () => {
108
+ process.chdir(originalCwd);
109
+
110
+ tempDir = await realpath(await createTempGitRepo());
111
+ overstoryDir = join(tempDir, ".overstory");
112
+ await mkdir(overstoryDir, { recursive: true });
113
+
114
+ // Write a minimal config.yaml so loadConfig succeeds
115
+ await Bun.write(
116
+ join(overstoryDir, "config.yaml"),
117
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
118
+ "\n",
119
+ ),
120
+ );
121
+
122
+ // Override cwd so stop commands find our temp project
123
+ process.chdir(tempDir);
124
+ });
125
+
126
+ afterEach(async () => {
127
+ process.chdir(originalCwd);
128
+ await cleanupTempDir(tempDir);
129
+ });
130
+
131
+ // --- Helpers ---
132
+
133
+ function makeAgentSession(overrides: Partial<AgentSession> = {}): AgentSession {
134
+ return {
135
+ id: `session-${Date.now()}-my-builder`,
136
+ agentName: "my-builder",
137
+ capability: "builder",
138
+ worktreePath: join(tempDir, ".overstory", "worktrees", "my-builder"),
139
+ branchName: "overstory/my-builder/bead-123",
140
+ beadId: "bead-123",
141
+ tmuxSession: "overstory-test-project-my-builder",
142
+ state: "working",
143
+ pid: 99999,
144
+ parentAgent: null,
145
+ depth: 2,
146
+ runId: null,
147
+ startedAt: new Date().toISOString(),
148
+ lastActivity: new Date().toISOString(),
149
+ escalationLevel: 0,
150
+ stalledSince: null,
151
+ ...overrides,
152
+ };
153
+ }
154
+
155
+ /** Capture stdout.write output during a function call. */
156
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
157
+ const chunks: string[] = [];
158
+ const originalWrite = process.stdout.write;
159
+ process.stdout.write = ((chunk: string) => {
160
+ chunks.push(chunk);
161
+ return true;
162
+ }) as typeof process.stdout.write;
163
+ try {
164
+ await fn();
165
+ } finally {
166
+ process.stdout.write = originalWrite;
167
+ }
168
+ return chunks.join("");
169
+ }
170
+
171
+ /** Capture stderr.write output during a function call. */
172
+ async function captureStderr(fn: () => Promise<void>): Promise<{ stderr: string; stdout: string }> {
173
+ const stderrChunks: string[] = [];
174
+ const stdoutChunks: string[] = [];
175
+ const origStderr = process.stderr.write;
176
+ const origStdout = process.stdout.write;
177
+ process.stderr.write = ((chunk: string) => {
178
+ stderrChunks.push(chunk);
179
+ return true;
180
+ }) as typeof process.stderr.write;
181
+ process.stdout.write = ((chunk: string) => {
182
+ stdoutChunks.push(chunk);
183
+ return true;
184
+ }) as typeof process.stdout.write;
185
+ try {
186
+ await fn();
187
+ } finally {
188
+ process.stderr.write = origStderr;
189
+ process.stdout.write = origStdout;
190
+ }
191
+ return { stderr: stderrChunks.join(""), stdout: stdoutChunks.join("") };
192
+ }
193
+
194
+ /** Build default deps with fake tmux and worktree. */
195
+ function makeDeps(
196
+ sessionAliveMap: Record<string, boolean> = {},
197
+ worktreeConfig?: { shouldFail?: boolean },
198
+ ): {
199
+ deps: StopDeps;
200
+ tmuxCalls: TmuxCallTracker;
201
+ worktreeCalls: WorktreeCallTracker;
202
+ } {
203
+ const { tmux, calls: tmuxCalls } = makeFakeTmux(sessionAliveMap);
204
+ const { worktree, calls: worktreeCalls } = makeFakeWorktree(worktreeConfig?.shouldFail);
205
+ return { deps: { _tmux: tmux, _worktree: worktree }, tmuxCalls, worktreeCalls };
206
+ }
207
+
208
+ // --- Tests ---
209
+
210
+ describe("stopCommand help", () => {
211
+ test("--help outputs help text", async () => {
212
+ const output = await captureStdout(() => stopCommand(["--help"]));
213
+ expect(output).toContain("overstory stop");
214
+ expect(output).toContain("<agent-name>");
215
+ expect(output).toContain("--force");
216
+ expect(output).toContain("--clean-worktree");
217
+ expect(output).toContain("--json");
218
+ });
219
+
220
+ test("-h outputs help text", async () => {
221
+ const output = await captureStdout(() => stopCommand(["-h"]));
222
+ expect(output).toContain("overstory stop");
223
+ expect(output).toContain("<agent-name>");
224
+ });
225
+ });
226
+
227
+ describe("stopCommand validation", () => {
228
+ test("throws ValidationError when no agent name provided", async () => {
229
+ const { deps } = makeDeps();
230
+ await expect(stopCommand([], deps)).rejects.toThrow(ValidationError);
231
+ });
232
+
233
+ test("throws ValidationError when only flags are provided (no agent name)", async () => {
234
+ const { deps } = makeDeps();
235
+ await expect(stopCommand(["--json"], deps)).rejects.toThrow(ValidationError);
236
+ });
237
+
238
+ test("throws AgentError when agent not found", async () => {
239
+ const { deps } = makeDeps();
240
+ await expect(stopCommand(["nonexistent-agent"], deps)).rejects.toThrow(AgentError);
241
+ });
242
+
243
+ test("throws AgentError when agent is already completed", async () => {
244
+ const session = makeAgentSession({ state: "completed" });
245
+ saveSessionsToDb([session]);
246
+
247
+ const { deps } = makeDeps();
248
+ await expect(stopCommand(["my-builder"], deps)).rejects.toThrow(AgentError);
249
+ await expect(stopCommand(["my-builder"], deps)).rejects.toThrow(/already completed/);
250
+ });
251
+
252
+ test("throws AgentError when agent is already zombie", async () => {
253
+ const session = makeAgentSession({ state: "zombie" });
254
+ saveSessionsToDb([session]);
255
+
256
+ const { deps } = makeDeps();
257
+ await expect(stopCommand(["my-builder"], deps)).rejects.toThrow(AgentError);
258
+ await expect(stopCommand(["my-builder"], deps)).rejects.toThrow(/zombie/);
259
+ });
260
+ });
261
+
262
+ describe("stopCommand stop behavior", () => {
263
+ test("stops a working agent (kills tmux, marks completed)", async () => {
264
+ const session = makeAgentSession({ state: "working" });
265
+ saveSessionsToDb([session]);
266
+
267
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: true });
268
+ const output = await captureStdout(() => stopCommand(["my-builder"], deps));
269
+
270
+ expect(output).toContain(`Agent "my-builder" stopped`);
271
+ expect(output).toContain(`Tmux session killed: ${session.tmuxSession}`);
272
+ expect(tmuxCalls.killSession).toHaveLength(1);
273
+ expect(tmuxCalls.killSession[0]?.name).toBe(session.tmuxSession);
274
+
275
+ // Verify state was updated in DB
276
+ const { store } = openSessionStore(overstoryDir);
277
+ const updated = store.getByName("my-builder");
278
+ store.close();
279
+ expect(updated?.state).toBe("completed");
280
+ });
281
+
282
+ test("stops a booting agent", async () => {
283
+ const session = makeAgentSession({ state: "booting" });
284
+ saveSessionsToDb([session]);
285
+
286
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: true });
287
+ await stopCommand(["my-builder"], deps);
288
+
289
+ expect(tmuxCalls.killSession).toHaveLength(1);
290
+ const { store } = openSessionStore(overstoryDir);
291
+ const updated = store.getByName("my-builder");
292
+ store.close();
293
+ expect(updated?.state).toBe("completed");
294
+ });
295
+
296
+ test("stops a stalled agent", async () => {
297
+ const session = makeAgentSession({ state: "stalled" });
298
+ saveSessionsToDb([session]);
299
+
300
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: true });
301
+ await stopCommand(["my-builder"], deps);
302
+
303
+ expect(tmuxCalls.killSession).toHaveLength(1);
304
+ const { store } = openSessionStore(overstoryDir);
305
+ const updated = store.getByName("my-builder");
306
+ store.close();
307
+ expect(updated?.state).toBe("completed");
308
+ });
309
+
310
+ test("handles already-dead tmux session gracefully (skips kill)", async () => {
311
+ const session = makeAgentSession({ state: "working" });
312
+ saveSessionsToDb([session]);
313
+
314
+ // tmux session is NOT alive
315
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: false });
316
+ const output = await captureStdout(() => stopCommand(["my-builder"], deps));
317
+
318
+ expect(output).toContain("Tmux session was already dead");
319
+ expect(tmuxCalls.killSession).toHaveLength(0);
320
+
321
+ // Session should still be marked completed
322
+ const { store } = openSessionStore(overstoryDir);
323
+ const updated = store.getByName("my-builder");
324
+ store.close();
325
+ expect(updated?.state).toBe("completed");
326
+ });
327
+ });
328
+
329
+ describe("stopCommand --json output", () => {
330
+ test("--json outputs correct JSON shape", async () => {
331
+ const session = makeAgentSession({ state: "working" });
332
+ saveSessionsToDb([session]);
333
+
334
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
335
+ const output = await captureStdout(() => stopCommand(["my-builder", "--json"], deps));
336
+
337
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
338
+ expect(parsed.stopped).toBe(true);
339
+ expect(parsed.agentName).toBe("my-builder");
340
+ expect(parsed.sessionId).toBe(session.id);
341
+ expect(parsed.capability).toBe("builder");
342
+ expect(parsed.tmuxKilled).toBe(true);
343
+ expect(parsed.worktreeRemoved).toBe(false);
344
+ expect(parsed.force).toBe(false);
345
+ });
346
+
347
+ test("--force flag is passed through to JSON output", async () => {
348
+ const session = makeAgentSession({ state: "working" });
349
+ saveSessionsToDb([session]);
350
+
351
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
352
+ const output = await captureStdout(() =>
353
+ stopCommand(["my-builder", "--json", "--force"], deps),
354
+ );
355
+
356
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
357
+ expect(parsed.force).toBe(true);
358
+ });
359
+ });
360
+
361
+ describe("stopCommand --clean-worktree", () => {
362
+ test("--clean-worktree removes worktree after stopping", async () => {
363
+ const session = makeAgentSession({ state: "working" });
364
+ saveSessionsToDb([session]);
365
+
366
+ const { deps, worktreeCalls } = makeDeps({ [session.tmuxSession]: true });
367
+ const output = await captureStdout(() => stopCommand(["my-builder", "--clean-worktree"], deps));
368
+
369
+ expect(output).toContain(`Worktree removed: ${session.worktreePath}`);
370
+ expect(worktreeCalls.remove).toHaveLength(1);
371
+ expect(worktreeCalls.remove[0]?.path).toBe(session.worktreePath);
372
+ });
373
+
374
+ test("--clean-worktree with --force passes force flags to removeWorktree", async () => {
375
+ const session = makeAgentSession({ state: "working" });
376
+ saveSessionsToDb([session]);
377
+
378
+ const { deps, worktreeCalls } = makeDeps({ [session.tmuxSession]: true });
379
+ await captureStdout(() => stopCommand(["my-builder", "--clean-worktree", "--force"], deps));
380
+
381
+ expect(worktreeCalls.remove).toHaveLength(1);
382
+ expect(worktreeCalls.remove[0]?.options?.force).toBe(true);
383
+ expect(worktreeCalls.remove[0]?.options?.forceBranch).toBe(true);
384
+ });
385
+
386
+ test("--clean-worktree failure is non-fatal (agent still stopped, warning on stderr)", async () => {
387
+ const session = makeAgentSession({ state: "working" });
388
+ saveSessionsToDb([session]);
389
+
390
+ const { deps } = makeDeps({ [session.tmuxSession]: true }, { shouldFail: true });
391
+ const { stderr, stdout } = await captureStderr(() =>
392
+ stopCommand(["my-builder", "--clean-worktree"], deps),
393
+ );
394
+
395
+ // Agent was still stopped
396
+ expect(stdout).toContain(`Agent "my-builder" stopped`);
397
+ // Warning written to stderr
398
+ expect(stderr).toContain("Warning: failed to remove worktree");
399
+
400
+ // Session is marked completed despite worktree failure
401
+ const { store } = openSessionStore(overstoryDir);
402
+ const updated = store.getByName("my-builder");
403
+ store.close();
404
+ expect(updated?.state).toBe("completed");
405
+ });
406
+
407
+ test("--clean-worktree with --json reflects worktreeRemoved=false on failure", async () => {
408
+ const session = makeAgentSession({ state: "working" });
409
+ saveSessionsToDb([session]);
410
+
411
+ const { deps } = makeDeps({ [session.tmuxSession]: true }, { shouldFail: true });
412
+ const { stdout } = await captureStderr(() =>
413
+ stopCommand(["my-builder", "--clean-worktree", "--json"], deps),
414
+ );
415
+
416
+ const parsed = JSON.parse(stdout.trim()) as Record<string, unknown>;
417
+ expect(parsed.stopped).toBe(true);
418
+ expect(parsed.worktreeRemoved).toBe(false);
419
+ });
420
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * CLI command: overstory stop <agent-name>
3
+ *
4
+ * Explicitly terminates a running agent by:
5
+ * 1. Looking up the agent session by name
6
+ * 2. Killing its tmux session (if alive)
7
+ * 3. Marking it as completed in the SessionStore
8
+ * 4. Optionally removing its worktree (--clean-worktree)
9
+ */
10
+
11
+ import { join } from "node:path";
12
+ import { loadConfig } from "../config.ts";
13
+ import { AgentError, ValidationError } from "../errors.ts";
14
+ import { openSessionStore } from "../sessions/compat.ts";
15
+ import { removeWorktree } from "../worktree/manager.ts";
16
+ import { isSessionAlive, killSession } from "../worktree/tmux.ts";
17
+
18
+ /** Dependency injection for testing. Uses real implementations when omitted. */
19
+ export interface StopDeps {
20
+ _tmux?: {
21
+ isSessionAlive: (name: string) => Promise<boolean>;
22
+ killSession: (name: string) => Promise<void>;
23
+ };
24
+ _worktree?: {
25
+ remove: (
26
+ repoRoot: string,
27
+ path: string,
28
+ options?: { force?: boolean; forceBranch?: boolean },
29
+ ) => Promise<void>;
30
+ };
31
+ }
32
+
33
+ const STOP_HELP = `overstory stop — Terminate a running agent
34
+
35
+ Usage: overstory stop <agent-name> [flags]
36
+
37
+ Arguments:
38
+ <agent-name> Name of the agent to stop
39
+
40
+ Options:
41
+ --force Force kill and force-delete branch when cleaning worktree
42
+ --clean-worktree Remove the agent's worktree after stopping
43
+ --json Output as JSON
44
+ --help, -h Show this help
45
+
46
+ Examples:
47
+ overstory stop my-builder
48
+ overstory stop my-builder --clean-worktree
49
+ overstory stop my-builder --clean-worktree --force
50
+ overstory stop my-builder --json`;
51
+
52
+ /**
53
+ * Entry point for `overstory stop <agent-name>`.
54
+ *
55
+ * @param args - CLI arguments after "stop"
56
+ * @param deps - Optional dependency injection for testing (tmux, worktree)
57
+ */
58
+ export async function stopCommand(args: string[], deps: StopDeps = {}): Promise<void> {
59
+ if (args.includes("--help") || args.includes("-h")) {
60
+ process.stdout.write(`${STOP_HELP}\n`);
61
+ return;
62
+ }
63
+
64
+ const json = args.includes("--json");
65
+ const force = args.includes("--force");
66
+ const cleanWorktree = args.includes("--clean-worktree");
67
+
68
+ // First non-flag arg is the agent name
69
+ const agentName = args.find((a) => !a.startsWith("-"));
70
+ if (!agentName) {
71
+ throw new ValidationError("Missing required argument: <agent-name>", {
72
+ field: "agentName",
73
+ value: "",
74
+ });
75
+ }
76
+
77
+ const tmux = deps._tmux ?? { isSessionAlive, killSession };
78
+ const worktree = deps._worktree ?? { remove: removeWorktree };
79
+
80
+ const cwd = process.cwd();
81
+ const config = await loadConfig(cwd);
82
+ const projectRoot = config.project.root;
83
+ const overstoryDir = join(projectRoot, ".overstory");
84
+
85
+ const { store } = openSessionStore(overstoryDir);
86
+ try {
87
+ const session = store.getByName(agentName);
88
+ if (!session) {
89
+ throw new AgentError(`Agent "${agentName}" not found`, { agentName });
90
+ }
91
+
92
+ if (session.state === "completed") {
93
+ throw new AgentError(`Agent "${agentName}" is already completed`, { agentName });
94
+ }
95
+
96
+ if (session.state === "zombie") {
97
+ throw new AgentError(`Agent "${agentName}" is already zombie (dead)`, { agentName });
98
+ }
99
+
100
+ // Kill tmux session if alive
101
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
102
+ if (alive) {
103
+ await tmux.killSession(session.tmuxSession);
104
+ }
105
+
106
+ // Mark session as completed
107
+ store.updateState(agentName, "completed");
108
+ store.updateLastActivity(agentName);
109
+
110
+ // Optionally remove worktree (best-effort, non-fatal)
111
+ let worktreeRemoved = false;
112
+ if (cleanWorktree && session.worktreePath) {
113
+ try {
114
+ await worktree.remove(projectRoot, session.worktreePath, {
115
+ force,
116
+ forceBranch: force,
117
+ });
118
+ worktreeRemoved = true;
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ process.stderr.write(`Warning: failed to remove worktree: ${msg}\n`);
122
+ }
123
+ }
124
+
125
+ if (json) {
126
+ process.stdout.write(
127
+ `${JSON.stringify({
128
+ stopped: true,
129
+ agentName,
130
+ sessionId: session.id,
131
+ capability: session.capability,
132
+ tmuxKilled: alive,
133
+ worktreeRemoved,
134
+ force,
135
+ })}\n`,
136
+ );
137
+ } else {
138
+ process.stdout.write(`Agent "${agentName}" stopped (session: ${session.id})\n`);
139
+ if (alive) {
140
+ process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
141
+ } else {
142
+ process.stdout.write(` Tmux session was already dead\n`);
143
+ }
144
+ if (cleanWorktree && worktreeRemoved) {
145
+ process.stdout.write(` Worktree removed: ${session.worktreePath}\n`);
146
+ }
147
+ }
148
+ } finally {
149
+ store.close();
150
+ }
151
+ }