@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,786 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createSessionStore } from "../sessions/store.ts";
6
+ import { cleanupTempDir, commitFile, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
7
+ import type { AgentSession } from "../types.ts";
8
+ import { createWorktree } from "../worktree/manager.ts";
9
+ import { worktreeCommand } from "./worktree.ts";
10
+
11
+ /**
12
+ * Tests for `overstory worktree` command.
13
+ *
14
+ * Uses real git worktrees in temp repos to test list and clean subcommands.
15
+ * Captures process.stdout.write to verify output formatting.
16
+ */
17
+
18
+ describe("worktreeCommand", () => {
19
+ let chunks: string[];
20
+ let originalWrite: typeof process.stdout.write;
21
+ let tempDir: string;
22
+ let originalCwd: string;
23
+
24
+ beforeEach(async () => {
25
+ // Spy on stdout
26
+ chunks = [];
27
+ originalWrite = process.stdout.write;
28
+ process.stdout.write = ((chunk: string) => {
29
+ chunks.push(chunk);
30
+ return true;
31
+ }) as typeof process.stdout.write;
32
+
33
+ // Create temp git repo with .overstory/config.yaml structure
34
+ tempDir = await createTempGitRepo();
35
+ // Normalize tempDir to resolve macOS /var -> /private/var symlink
36
+ tempDir = realpathSync(tempDir);
37
+ const overstoryDir = join(tempDir, ".overstory");
38
+ await mkdir(overstoryDir, { recursive: true });
39
+ await Bun.write(
40
+ join(overstoryDir, "config.yaml"),
41
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
42
+ );
43
+
44
+ // Change to temp dir so loadConfig() works
45
+ originalCwd = process.cwd();
46
+ process.chdir(tempDir);
47
+ });
48
+
49
+ afterEach(async () => {
50
+ process.stdout.write = originalWrite;
51
+ process.chdir(originalCwd);
52
+ await cleanupTempDir(tempDir);
53
+ });
54
+
55
+ function output(): string {
56
+ return chunks.join("");
57
+ }
58
+
59
+ /**
60
+ * Helper to create an AgentSession with sensible defaults.
61
+ * Uses FAKE tmux session names to avoid real tmux calls during tests.
62
+ */
63
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
64
+ return {
65
+ id: "session-test",
66
+ agentName: "test-agent",
67
+ capability: "builder",
68
+ worktreePath: join(tempDir, ".overstory", "worktrees", "test-agent"),
69
+ branchName: "overstory/test-agent/task-1",
70
+ beadId: "task-1",
71
+ tmuxSession: "overstory-test-agent-fake", // FAKE tmux session name
72
+ state: "working",
73
+ pid: 12345,
74
+ parentAgent: null,
75
+ depth: 0,
76
+ runId: null,
77
+ startedAt: new Date().toISOString(),
78
+ lastActivity: new Date().toISOString(),
79
+ escalationLevel: 0,
80
+ stalledSince: null,
81
+ ...overrides,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Helper to write sessions to SessionStore (sessions.db) in the temp repo.
87
+ */
88
+ function writeSessionsToStore(sessions: AgentSession[]): void {
89
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
90
+ const store = createSessionStore(dbPath);
91
+ for (const session of sessions) {
92
+ store.upsert(session);
93
+ }
94
+ store.close();
95
+ }
96
+
97
+ describe("help flags", () => {
98
+ test("--help shows help text", async () => {
99
+ await worktreeCommand(["--help"]);
100
+ const out = output();
101
+
102
+ expect(out).toContain("overstory worktree");
103
+ expect(out).toContain("list");
104
+ expect(out).toContain("clean");
105
+ expect(out).toContain("--json");
106
+ });
107
+
108
+ test("-h shows help text", async () => {
109
+ await worktreeCommand(["-h"]);
110
+ const out = output();
111
+
112
+ expect(out).toContain("overstory worktree");
113
+ expect(out).toContain("list");
114
+ });
115
+ });
116
+
117
+ describe("validation", () => {
118
+ test("unknown subcommand throws ValidationError", async () => {
119
+ await expect(worktreeCommand(["unknown"])).rejects.toThrow(
120
+ "Unknown worktree subcommand: unknown",
121
+ );
122
+ });
123
+
124
+ test("missing subcommand throws ValidationError with (none)", async () => {
125
+ await expect(worktreeCommand([])).rejects.toThrow("Unknown worktree subcommand: (none)");
126
+ });
127
+ });
128
+
129
+ describe("worktree list", () => {
130
+ test("no overstory worktrees returns empty message", async () => {
131
+ await worktreeCommand(["list"]);
132
+ const out = output();
133
+
134
+ expect(out).toBe("No agent worktrees found.\n");
135
+ });
136
+
137
+ test("with overstory worktrees lists them with agent info", async () => {
138
+ // Create a real git worktree with overstory/ prefix branch
139
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
140
+ await mkdir(worktreesDir, { recursive: true });
141
+
142
+ const worktreePath = join(worktreesDir, "test-agent");
143
+ await runGitInDir(tempDir, [
144
+ "worktree",
145
+ "add",
146
+ worktreePath,
147
+ "-b",
148
+ "overstory/test-agent/task-1",
149
+ ]);
150
+
151
+ // Write sessions.db to associate worktree with agent
152
+ writeSessionsToStore([
153
+ {
154
+ id: "session-1",
155
+ agentName: "test-agent",
156
+ capability: "builder",
157
+ worktreePath,
158
+ branchName: "overstory/test-agent/task-1",
159
+ beadId: "task-1",
160
+ tmuxSession: "overstory-test-agent",
161
+ state: "working",
162
+ pid: 12345,
163
+ parentAgent: null,
164
+ depth: 0,
165
+ runId: null,
166
+ startedAt: new Date().toISOString(),
167
+ lastActivity: new Date().toISOString(),
168
+ escalationLevel: 0,
169
+ stalledSince: null,
170
+ },
171
+ ]);
172
+
173
+ await worktreeCommand(["list"]);
174
+ const out = output();
175
+
176
+ expect(out).toContain("🌳 Agent worktrees: 1");
177
+ expect(out).toContain("overstory/test-agent/task-1");
178
+ expect(out).toContain("Agent: test-agent");
179
+ expect(out).toContain("State: working");
180
+ expect(out).toContain("Task: task-1");
181
+ expect(out).toContain(`Path: ${worktreePath}`);
182
+ });
183
+
184
+ test("--json flag outputs valid JSON array", async () => {
185
+ // Create a real git worktree
186
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
187
+ await mkdir(worktreesDir, { recursive: true });
188
+
189
+ const worktreePath = join(worktreesDir, "test-agent");
190
+ await runGitInDir(tempDir, [
191
+ "worktree",
192
+ "add",
193
+ worktreePath,
194
+ "-b",
195
+ "overstory/test-agent/task-1",
196
+ ]);
197
+
198
+ // Write sessions.db
199
+ writeSessionsToStore([
200
+ {
201
+ id: "session-1",
202
+ agentName: "test-agent",
203
+ capability: "builder",
204
+ worktreePath,
205
+ branchName: "overstory/test-agent/task-1",
206
+ beadId: "task-1",
207
+ tmuxSession: "overstory-test-agent",
208
+ state: "working",
209
+ pid: 12345,
210
+ parentAgent: null,
211
+ depth: 0,
212
+ runId: null,
213
+ startedAt: new Date().toISOString(),
214
+ lastActivity: new Date().toISOString(),
215
+ escalationLevel: 0,
216
+ stalledSince: null,
217
+ },
218
+ ]);
219
+
220
+ await worktreeCommand(["list", "--json"]);
221
+ const out = output();
222
+
223
+ const parsed = JSON.parse(out.trim()) as Array<{
224
+ path: string;
225
+ branch: string;
226
+ head: string;
227
+ agentName: string | null;
228
+ state: string | null;
229
+ beadId: string | null;
230
+ }>;
231
+
232
+ expect(parsed).toHaveLength(1);
233
+ expect(parsed[0]?.path).toBe(worktreePath);
234
+ expect(parsed[0]?.branch).toBe("overstory/test-agent/task-1");
235
+ expect(parsed[0]?.agentName).toBe("test-agent");
236
+ expect(parsed[0]?.state).toBe("working");
237
+ expect(parsed[0]?.beadId).toBe("task-1");
238
+ });
239
+
240
+ test("worktrees without sessions show unknown state", async () => {
241
+ // Create a worktree but no sessions.db entry
242
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
243
+ await mkdir(worktreesDir, { recursive: true });
244
+
245
+ const worktreePath = join(worktreesDir, "orphan-agent");
246
+ await runGitInDir(tempDir, [
247
+ "worktree",
248
+ "add",
249
+ worktreePath,
250
+ "-b",
251
+ "overstory/orphan-agent/task-2",
252
+ ]);
253
+
254
+ await worktreeCommand(["list"]);
255
+ const out = output();
256
+
257
+ expect(out).toContain("overstory/orphan-agent/task-2");
258
+ expect(out).toContain("Agent: ?");
259
+ expect(out).toContain("State: unknown");
260
+ expect(out).toContain("Task: ?");
261
+ });
262
+ });
263
+
264
+ describe("worktree clean", () => {
265
+ test("no overstory worktrees returns empty message", async () => {
266
+ await worktreeCommand(["clean"]);
267
+ const out = output();
268
+
269
+ expect(out).toBe("No worktrees to clean.\n");
270
+ });
271
+
272
+ test("with completed agent worktree removes it and reports count", async () => {
273
+ // Create a real git worktree
274
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
275
+ await mkdir(worktreesDir, { recursive: true });
276
+
277
+ const worktreePath = join(worktreesDir, "completed-agent");
278
+ await runGitInDir(tempDir, [
279
+ "worktree",
280
+ "add",
281
+ worktreePath,
282
+ "-b",
283
+ "overstory/completed-agent/task-done",
284
+ ]);
285
+
286
+ // Write sessions.db with completed state
287
+ writeSessionsToStore([
288
+ {
289
+ id: "session-1",
290
+ agentName: "completed-agent",
291
+ capability: "builder",
292
+ worktreePath,
293
+ branchName: "overstory/completed-agent/task-done",
294
+ beadId: "task-done",
295
+ tmuxSession: "overstory-completed-agent",
296
+ state: "completed",
297
+ pid: 12345,
298
+ parentAgent: null,
299
+ depth: 0,
300
+ runId: null,
301
+ startedAt: new Date().toISOString(),
302
+ lastActivity: new Date().toISOString(),
303
+ escalationLevel: 0,
304
+ stalledSince: null,
305
+ },
306
+ ]);
307
+
308
+ await worktreeCommand(["clean"]);
309
+ const out = output();
310
+
311
+ expect(out).toContain("🗑️ Removed: overstory/completed-agent/task-done");
312
+ expect(out).toContain("Cleaned 1 worktree");
313
+
314
+ // Verify the worktree directory is gone
315
+ const worktreeExists = await Bun.file(worktreePath).exists();
316
+ expect(worktreeExists).toBe(false);
317
+
318
+ // Verify the branch is deleted
319
+ const branchListProc = Bun.spawn(["git", "branch", "--list", "overstory/completed-agent/*"], {
320
+ cwd: tempDir,
321
+ stdout: "pipe",
322
+ });
323
+ const branchList = await new Response(branchListProc.stdout).text();
324
+ expect(branchList.trim()).toBe("");
325
+ });
326
+
327
+ test("--json flag returns JSON with cleaned/failed/pruned arrays", async () => {
328
+ // Create a completed worktree
329
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
330
+ await mkdir(worktreesDir, { recursive: true });
331
+
332
+ const worktreePath = join(worktreesDir, "done-agent");
333
+ await runGitInDir(tempDir, [
334
+ "worktree",
335
+ "add",
336
+ worktreePath,
337
+ "-b",
338
+ "overstory/done-agent/task-x",
339
+ ]);
340
+
341
+ writeSessionsToStore([
342
+ {
343
+ id: "session-1",
344
+ agentName: "done-agent",
345
+ capability: "builder",
346
+ worktreePath,
347
+ branchName: "overstory/done-agent/task-x",
348
+ beadId: "task-x",
349
+ tmuxSession: "overstory-done-agent",
350
+ state: "completed",
351
+ pid: 12345,
352
+ parentAgent: null,
353
+ depth: 0,
354
+ runId: null,
355
+ startedAt: new Date().toISOString(),
356
+ lastActivity: new Date().toISOString(),
357
+ escalationLevel: 0,
358
+ stalledSince: null,
359
+ },
360
+ ]);
361
+
362
+ await worktreeCommand(["clean", "--json"]);
363
+ const out = output();
364
+
365
+ const parsed = JSON.parse(out.trim()) as {
366
+ cleaned: string[];
367
+ failed: string[];
368
+ pruned: number;
369
+ };
370
+
371
+ expect(parsed.cleaned).toEqual(["overstory/done-agent/task-x"]);
372
+ expect(parsed.failed).toEqual([]);
373
+ expect(parsed.pruned).toBe(1); // The zombie session was pruned
374
+ });
375
+
376
+ test("zombie sessions whose worktree paths no longer exist get pruned from sessions.db", async () => {
377
+ // Create sessions.db with a zombie entry whose worktree doesn't exist
378
+ const nonExistentPath = join(tempDir, ".overstory", "worktrees", "ghost-agent");
379
+ writeSessionsToStore([
380
+ {
381
+ id: "session-ghost",
382
+ agentName: "ghost-agent",
383
+ capability: "builder",
384
+ worktreePath: nonExistentPath,
385
+ branchName: "overstory/ghost-agent/task-ghost",
386
+ beadId: "task-ghost",
387
+ tmuxSession: "overstory-ghost-agent",
388
+ state: "zombie",
389
+ pid: null,
390
+ parentAgent: null,
391
+ depth: 0,
392
+ runId: null,
393
+ startedAt: new Date().toISOString(),
394
+ lastActivity: new Date().toISOString(),
395
+ escalationLevel: 0,
396
+ stalledSince: null,
397
+ },
398
+ ]);
399
+
400
+ await worktreeCommand(["clean", "--json"]);
401
+ const out = output();
402
+
403
+ const parsed = JSON.parse(out.trim()) as {
404
+ cleaned: string[];
405
+ failed: string[];
406
+ pruned: number;
407
+ };
408
+
409
+ expect(parsed.pruned).toBe(1);
410
+
411
+ // Verify sessions.db no longer contains the zombie
412
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
413
+ const store = createSessionStore(dbPath);
414
+ const updatedSessions = store.getAll();
415
+ store.close();
416
+ expect(updatedSessions).toHaveLength(0);
417
+ });
418
+
419
+ test("stalled agents are cleaned like working agents (not by default)", async () => {
420
+ // Create a worktree with stalled state
421
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
422
+ await mkdir(worktreesDir, { recursive: true });
423
+
424
+ const worktreePath = join(worktreesDir, "stalled-agent");
425
+ await runGitInDir(tempDir, [
426
+ "worktree",
427
+ "add",
428
+ worktreePath,
429
+ "-b",
430
+ "overstory/stalled-agent/task-stuck",
431
+ ]);
432
+
433
+ writeSessionsToStore([
434
+ {
435
+ id: "session-1",
436
+ agentName: "stalled-agent",
437
+ capability: "builder",
438
+ worktreePath,
439
+ branchName: "overstory/stalled-agent/task-stuck",
440
+ beadId: "task-stuck",
441
+ tmuxSession: "overstory-stalled-agent",
442
+ state: "stalled",
443
+ pid: 12345,
444
+ parentAgent: null,
445
+ depth: 0,
446
+ runId: null,
447
+ startedAt: new Date().toISOString(),
448
+ lastActivity: new Date().toISOString(),
449
+ escalationLevel: 0,
450
+ stalledSince: new Date().toISOString(),
451
+ },
452
+ ]);
453
+
454
+ await worktreeCommand(["clean"]);
455
+ const out = output();
456
+
457
+ // Stalled agents should not be cleaned by default (only completed/zombie are cleaned)
458
+ expect(out).toBe("No worktrees to clean.\n");
459
+ });
460
+
461
+ test("--completed flag only cleans completed agents", async () => {
462
+ // Create two worktrees using createWorktree
463
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
464
+ await mkdir(worktreesDir, { recursive: true });
465
+
466
+ const { path: completedPath } = await createWorktree({
467
+ repoRoot: tempDir,
468
+ baseDir: worktreesDir,
469
+ agentName: "completed-agent",
470
+ baseBranch: "main",
471
+ beadId: "task-done",
472
+ });
473
+
474
+ const { path: workingPath } = await createWorktree({
475
+ repoRoot: tempDir,
476
+ baseDir: worktreesDir,
477
+ agentName: "working-agent",
478
+ baseBranch: "main",
479
+ beadId: "task-wip",
480
+ });
481
+
482
+ // Write sessions.db with both agents
483
+ writeSessionsToStore([
484
+ makeSession({
485
+ id: "session-1",
486
+ agentName: "completed-agent",
487
+ worktreePath: completedPath,
488
+ branchName: "overstory/completed-agent/task-done",
489
+ beadId: "task-done",
490
+ tmuxSession: "overstory-completed-agent-fake",
491
+ state: "completed",
492
+ }),
493
+ makeSession({
494
+ id: "session-2",
495
+ agentName: "working-agent",
496
+ worktreePath: workingPath,
497
+ branchName: "overstory/working-agent/task-wip",
498
+ beadId: "task-wip",
499
+ tmuxSession: "overstory-working-agent-fake",
500
+ state: "working",
501
+ pid: 12346,
502
+ }),
503
+ ]);
504
+
505
+ await worktreeCommand(["clean", "--completed"]);
506
+ const out = output();
507
+
508
+ expect(out).toContain("Cleaned 1 worktree");
509
+
510
+ // Verify only the completed worktree is removed
511
+ expect(existsSync(completedPath)).toBe(false);
512
+ expect(existsSync(workingPath)).toBe(true);
513
+ });
514
+
515
+ test("--all flag cleans all worktrees regardless of state", async () => {
516
+ // Create three worktrees with different states
517
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
518
+ await mkdir(worktreesDir, { recursive: true });
519
+
520
+ const { path: completedPath } = await createWorktree({
521
+ repoRoot: tempDir,
522
+ baseDir: worktreesDir,
523
+ agentName: "completed-agent",
524
+ baseBranch: "main",
525
+ beadId: "task-done",
526
+ });
527
+
528
+ const { path: workingPath } = await createWorktree({
529
+ repoRoot: tempDir,
530
+ baseDir: worktreesDir,
531
+ agentName: "working-agent",
532
+ baseBranch: "main",
533
+ beadId: "task-wip",
534
+ });
535
+
536
+ const { path: stalledPath } = await createWorktree({
537
+ repoRoot: tempDir,
538
+ baseDir: worktreesDir,
539
+ agentName: "stalled-agent",
540
+ baseBranch: "main",
541
+ beadId: "task-stuck",
542
+ });
543
+
544
+ // Write sessions with different states
545
+ writeSessionsToStore([
546
+ makeSession({
547
+ id: "session-1",
548
+ agentName: "completed-agent",
549
+ worktreePath: completedPath,
550
+ branchName: "overstory/completed-agent/task-done",
551
+ beadId: "task-done",
552
+ state: "completed",
553
+ }),
554
+ makeSession({
555
+ id: "session-2",
556
+ agentName: "working-agent",
557
+ worktreePath: workingPath,
558
+ branchName: "overstory/working-agent/task-wip",
559
+ beadId: "task-wip",
560
+ state: "working",
561
+ }),
562
+ makeSession({
563
+ id: "session-3",
564
+ agentName: "stalled-agent",
565
+ worktreePath: stalledPath,
566
+ branchName: "overstory/stalled-agent/task-stuck",
567
+ beadId: "task-stuck",
568
+ state: "stalled",
569
+ }),
570
+ ]);
571
+
572
+ await worktreeCommand(["clean", "--all"]);
573
+ const out = output();
574
+
575
+ expect(out).toContain("Cleaned 3 worktrees");
576
+
577
+ // Verify all worktrees are removed
578
+ expect(existsSync(completedPath)).toBe(false);
579
+ expect(existsSync(workingPath)).toBe(false);
580
+ expect(existsSync(stalledPath)).toBe(false);
581
+ });
582
+
583
+ test("multiple completed worktrees reports correct count", async () => {
584
+ // Create two completed worktrees
585
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
586
+ await mkdir(worktreesDir, { recursive: true });
587
+
588
+ const path1 = join(worktreesDir, "agent-1");
589
+ await runGitInDir(tempDir, ["worktree", "add", path1, "-b", "overstory/agent-1/task-1"]);
590
+
591
+ const path2 = join(worktreesDir, "agent-2");
592
+ await runGitInDir(tempDir, ["worktree", "add", path2, "-b", "overstory/agent-2/task-2"]);
593
+
594
+ writeSessionsToStore([
595
+ {
596
+ id: "session-1",
597
+ agentName: "agent-1",
598
+ capability: "builder",
599
+ worktreePath: path1,
600
+ branchName: "overstory/agent-1/task-1",
601
+ beadId: "task-1",
602
+ tmuxSession: "overstory-agent-1",
603
+ state: "completed",
604
+ pid: 12345,
605
+ parentAgent: null,
606
+ depth: 0,
607
+ runId: null,
608
+ startedAt: new Date().toISOString(),
609
+ lastActivity: new Date().toISOString(),
610
+ escalationLevel: 0,
611
+ stalledSince: null,
612
+ },
613
+ {
614
+ id: "session-2",
615
+ agentName: "agent-2",
616
+ capability: "builder",
617
+ worktreePath: path2,
618
+ branchName: "overstory/agent-2/task-2",
619
+ beadId: "task-2",
620
+ tmuxSession: "overstory-agent-2",
621
+ state: "completed",
622
+ pid: 12346,
623
+ parentAgent: null,
624
+ depth: 0,
625
+ runId: null,
626
+ startedAt: new Date().toISOString(),
627
+ lastActivity: new Date().toISOString(),
628
+ escalationLevel: 0,
629
+ stalledSince: null,
630
+ },
631
+ ]);
632
+
633
+ await worktreeCommand(["clean"]);
634
+ const out = output();
635
+
636
+ expect(out).toContain("Cleaned 2 worktrees");
637
+ });
638
+
639
+ test("without --force, skips worktrees with unmerged branches and prints warning", async () => {
640
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
641
+ await mkdir(worktreesDir, { recursive: true });
642
+
643
+ const { path: wtPath } = await createWorktree({
644
+ repoRoot: tempDir,
645
+ baseDir: worktreesDir,
646
+ agentName: "unmerged-agent",
647
+ baseBranch: "main",
648
+ beadId: "task-unmerged",
649
+ });
650
+
651
+ // Add an unmerged commit
652
+ await commitFile(wtPath, "work.ts", "export const y = 2;", "unmerged work");
653
+
654
+ writeSessionsToStore([
655
+ makeSession({
656
+ id: "session-u",
657
+ agentName: "unmerged-agent",
658
+ worktreePath: wtPath,
659
+ branchName: "overstory/unmerged-agent/task-unmerged",
660
+ beadId: "task-unmerged",
661
+ state: "completed",
662
+ }),
663
+ ]);
664
+
665
+ await worktreeCommand(["clean"]);
666
+ const out = output();
667
+
668
+ // Worktree should NOT have been removed
669
+ expect(existsSync(wtPath)).toBe(true);
670
+ // Warning should be printed
671
+ expect(out).toContain("Skipped 1 worktree");
672
+ expect(out).toContain("overstory/unmerged-agent/task-unmerged");
673
+ expect(out).toContain("--force");
674
+ });
675
+
676
+ test("with --force, deletes worktrees with unmerged branches", async () => {
677
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
678
+ await mkdir(worktreesDir, { recursive: true });
679
+
680
+ const { path: wtPath } = await createWorktree({
681
+ repoRoot: tempDir,
682
+ baseDir: worktreesDir,
683
+ agentName: "unmerged-agent",
684
+ baseBranch: "main",
685
+ beadId: "task-force",
686
+ });
687
+
688
+ // Add an unmerged commit
689
+ await commitFile(wtPath, "work.ts", "export const y = 2;", "unmerged work");
690
+
691
+ writeSessionsToStore([
692
+ makeSession({
693
+ id: "session-f",
694
+ agentName: "unmerged-agent",
695
+ worktreePath: wtPath,
696
+ branchName: "overstory/unmerged-agent/task-force",
697
+ beadId: "task-force",
698
+ state: "completed",
699
+ }),
700
+ ]);
701
+
702
+ await worktreeCommand(["clean", "--force"]);
703
+ const out = output();
704
+
705
+ // Worktree should be removed
706
+ expect(existsSync(wtPath)).toBe(false);
707
+ expect(out).toContain("🗑️ Removed: overstory/unmerged-agent/task-force");
708
+ });
709
+
710
+ test("without --force, removes worktrees whose branches ARE merged", async () => {
711
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
712
+ await mkdir(worktreesDir, { recursive: true });
713
+
714
+ const { path: wtPath, branch } = await createWorktree({
715
+ repoRoot: tempDir,
716
+ baseDir: worktreesDir,
717
+ agentName: "merged-agent",
718
+ baseBranch: "main",
719
+ beadId: "task-merged",
720
+ });
721
+
722
+ // Add a commit and merge it into main
723
+ await commitFile(wtPath, "work.ts", "export const z = 3;", "work to merge");
724
+ await runGitInDir(tempDir, ["merge", "--no-ff", branch, "-m", "merge feature"]);
725
+
726
+ writeSessionsToStore([
727
+ makeSession({
728
+ id: "session-m",
729
+ agentName: "merged-agent",
730
+ worktreePath: wtPath,
731
+ branchName: branch,
732
+ beadId: "task-merged",
733
+ state: "completed",
734
+ }),
735
+ ]);
736
+
737
+ await worktreeCommand(["clean"]);
738
+ const out = output();
739
+
740
+ // Merged worktree should be cleaned
741
+ expect(existsSync(wtPath)).toBe(false);
742
+ expect(out).toContain("Cleaned 1 worktree");
743
+ });
744
+
745
+ test("--json output includes skipped array for unmerged branches", async () => {
746
+ const worktreesDir = join(tempDir, ".overstory", "worktrees");
747
+ await mkdir(worktreesDir, { recursive: true });
748
+
749
+ const { path: wtPath } = await createWorktree({
750
+ repoRoot: tempDir,
751
+ baseDir: worktreesDir,
752
+ agentName: "unmerged-json-agent",
753
+ baseBranch: "main",
754
+ beadId: "task-json",
755
+ });
756
+
757
+ // Add an unmerged commit
758
+ await commitFile(wtPath, "work.ts", "export const w = 4;", "unmerged work");
759
+
760
+ writeSessionsToStore([
761
+ makeSession({
762
+ id: "session-j",
763
+ agentName: "unmerged-json-agent",
764
+ worktreePath: wtPath,
765
+ branchName: "overstory/unmerged-json-agent/task-json",
766
+ beadId: "task-json",
767
+ state: "completed",
768
+ }),
769
+ ]);
770
+
771
+ await worktreeCommand(["clean", "--json"]);
772
+ const out = output();
773
+
774
+ const parsed = JSON.parse(out.trim()) as {
775
+ cleaned: string[];
776
+ failed: string[];
777
+ skipped: string[];
778
+ pruned: number;
779
+ mailPurged: number;
780
+ };
781
+
782
+ expect(parsed.cleaned).toEqual([]);
783
+ expect(parsed.skipped).toEqual(["overstory/unmerged-json-agent/task-json"]);
784
+ });
785
+ });
786
+ });