@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
});
|