@os-eco/overstory-cli 0.8.5 → 0.8.6
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/README.md +2 -1
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +420 -1
- package/src/commands/coordinator.ts +173 -1
- package/src/commands/init.test.ts +137 -0
- package/src/commands/init.ts +57 -1
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.ts +312 -322
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +243 -19
- package/src/merge/resolver.ts +235 -95
- package/src/runtimes/pi.test.ts +118 -1
- package/src/runtimes/pi.ts +61 -12
- package/src/types.ts +17 -0
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +66 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
package/src/commands/spec.ts
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
import { mkdir } from "node:fs/promises";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { ValidationError } from "../errors.ts";
|
|
14
|
+
import { jsonOutput } from "../json.ts";
|
|
14
15
|
import { printSuccess } from "../logging/color.ts";
|
|
15
16
|
|
|
16
17
|
export interface SpecWriteOptions {
|
|
17
18
|
body?: string;
|
|
18
19
|
agent?: string;
|
|
20
|
+
json?: boolean;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -94,6 +96,10 @@ export async function specWriteCommand(taskId: string, opts: SpecWriteOptions):
|
|
|
94
96
|
const { resolveProjectRoot } = await import("../config.ts");
|
|
95
97
|
const projectRoot = await resolveProjectRoot(process.cwd());
|
|
96
98
|
|
|
97
|
-
await writeSpec(projectRoot, taskId, body, opts.agent);
|
|
98
|
-
|
|
99
|
+
const specPath = await writeSpec(projectRoot, taskId, body, opts.agent);
|
|
100
|
+
if (opts.json) {
|
|
101
|
+
jsonOutput("spec-write", { taskId, path: specPath });
|
|
102
|
+
} else {
|
|
103
|
+
printSuccess("Spec written", taskId);
|
|
104
|
+
}
|
|
99
105
|
}
|
|
@@ -20,6 +20,26 @@ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
|
20
20
|
import type { AgentSession } from "../types.ts";
|
|
21
21
|
import { type StopDeps, stopCommand } from "./stop.ts";
|
|
22
22
|
|
|
23
|
+
// --- Fake Git (for branch deletion) ---
|
|
24
|
+
|
|
25
|
+
interface GitCallTracker {
|
|
26
|
+
deleteBranch: Array<{ repoRoot: string; branch: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeFakeGit(shouldSucceed = true): {
|
|
30
|
+
git: NonNullable<StopDeps["_git"]>;
|
|
31
|
+
calls: GitCallTracker;
|
|
32
|
+
} {
|
|
33
|
+
const calls: GitCallTracker = { deleteBranch: [] };
|
|
34
|
+
const git: NonNullable<StopDeps["_git"]> = {
|
|
35
|
+
deleteBranch: async (repoRoot: string, branch: string): Promise<boolean> => {
|
|
36
|
+
calls.deleteBranch.push({ repoRoot, branch });
|
|
37
|
+
return shouldSucceed;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
return { git, calls };
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
// --- Fake Process (for headless agents) ---
|
|
24
44
|
|
|
25
45
|
/** Track calls to fake process for assertions. */
|
|
@@ -224,18 +244,26 @@ async function captureStderr(fn: () => Promise<void>): Promise<{ stderr: string;
|
|
|
224
244
|
return { stderr: stderrChunks.join(""), stdout: stdoutChunks.join("") };
|
|
225
245
|
}
|
|
226
246
|
|
|
227
|
-
/** Build default deps with fake tmux and
|
|
247
|
+
/** Build default deps with fake tmux, worktree, and git. */
|
|
228
248
|
function makeDeps(
|
|
229
249
|
sessionAliveMap: Record<string, boolean> = {},
|
|
230
250
|
worktreeConfig?: { shouldFail?: boolean },
|
|
251
|
+
gitConfig?: { shouldSucceed?: boolean },
|
|
231
252
|
): {
|
|
232
253
|
deps: StopDeps;
|
|
233
254
|
tmuxCalls: TmuxCallTracker;
|
|
234
255
|
worktreeCalls: WorktreeCallTracker;
|
|
256
|
+
gitCalls: GitCallTracker;
|
|
235
257
|
} {
|
|
236
258
|
const { tmux, calls: tmuxCalls } = makeFakeTmux(sessionAliveMap);
|
|
237
259
|
const { worktree, calls: worktreeCalls } = makeFakeWorktree(worktreeConfig?.shouldFail);
|
|
238
|
-
|
|
260
|
+
const { git, calls: gitCalls } = makeFakeGit(gitConfig?.shouldSucceed ?? true);
|
|
261
|
+
return {
|
|
262
|
+
deps: { _tmux: tmux, _worktree: worktree, _git: git },
|
|
263
|
+
tmuxCalls,
|
|
264
|
+
worktreeCalls,
|
|
265
|
+
gitCalls,
|
|
266
|
+
};
|
|
239
267
|
}
|
|
240
268
|
|
|
241
269
|
// --- Tests ---
|
|
@@ -251,13 +279,14 @@ describe("stopCommand validation", () => {
|
|
|
251
279
|
await expect(stopCommand("nonexistent-agent", {}, deps)).rejects.toThrow(AgentError);
|
|
252
280
|
});
|
|
253
281
|
|
|
254
|
-
test("throws AgentError when agent is already completed", async () => {
|
|
282
|
+
test("throws AgentError when agent is already completed (without --clean-worktree)", async () => {
|
|
255
283
|
const session = makeAgentSession({ state: "completed" });
|
|
256
284
|
saveSessionsToDb([session]);
|
|
257
285
|
|
|
258
286
|
const { deps } = makeDeps();
|
|
259
287
|
await expect(stopCommand("my-builder", {}, deps)).rejects.toThrow(AgentError);
|
|
260
288
|
await expect(stopCommand("my-builder", {}, deps)).rejects.toThrow(/already completed/);
|
|
289
|
+
await expect(stopCommand("my-builder", {}, deps)).rejects.toThrow(/--clean-worktree/);
|
|
261
290
|
});
|
|
262
291
|
|
|
263
292
|
test("succeeds when agent is zombie (cleanup, no error)", async () => {
|
|
@@ -318,6 +347,65 @@ describe("stopCommand zombie cleanup", () => {
|
|
|
318
347
|
});
|
|
319
348
|
});
|
|
320
349
|
|
|
350
|
+
describe("stopCommand completed agent cleanup", () => {
|
|
351
|
+
test("completed + --clean-worktree removes worktree and branch", async () => {
|
|
352
|
+
const session = makeAgentSession({ state: "completed" });
|
|
353
|
+
saveSessionsToDb([session]);
|
|
354
|
+
|
|
355
|
+
const { deps, tmuxCalls, worktreeCalls, gitCalls } = makeDeps();
|
|
356
|
+
const output = await captureStdout(() =>
|
|
357
|
+
stopCommand("my-builder", { cleanWorktree: true }, deps),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
expect(output).toContain("Agent stopped");
|
|
361
|
+
expect(output).toContain("already completed");
|
|
362
|
+
expect(output).toContain(`Worktree removed`);
|
|
363
|
+
expect(output).toContain(`Branch deleted`);
|
|
364
|
+
|
|
365
|
+
// No kill operations
|
|
366
|
+
expect(tmuxCalls.isSessionAlive).toHaveLength(0);
|
|
367
|
+
expect(tmuxCalls.killSession).toHaveLength(0);
|
|
368
|
+
|
|
369
|
+
// Worktree removed
|
|
370
|
+
expect(worktreeCalls.remove).toHaveLength(1);
|
|
371
|
+
|
|
372
|
+
// Branch deleted
|
|
373
|
+
expect(gitCalls.deleteBranch).toHaveLength(1);
|
|
374
|
+
expect(gitCalls.deleteBranch[0]?.branch).toBe(session.branchName);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("completed + --clean-worktree + --json includes wasCompleted: true", async () => {
|
|
378
|
+
const session = makeAgentSession({ state: "completed" });
|
|
379
|
+
saveSessionsToDb([session]);
|
|
380
|
+
|
|
381
|
+
const { deps } = makeDeps();
|
|
382
|
+
const output = await captureStdout(() =>
|
|
383
|
+
stopCommand("my-builder", { cleanWorktree: true, json: true }, deps),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
|
|
387
|
+
expect(parsed.success).toBe(true);
|
|
388
|
+
expect(parsed.stopped).toBe(true);
|
|
389
|
+
expect(parsed.wasCompleted).toBe(true);
|
|
390
|
+
expect(parsed.tmuxKilled).toBe(false);
|
|
391
|
+
expect(parsed.pidKilled).toBe(false);
|
|
392
|
+
expect(parsed.worktreeRemoved).toBe(true);
|
|
393
|
+
expect(parsed.branchDeleted).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("branch deletion failure is non-fatal for completed agent", async () => {
|
|
397
|
+
const session = makeAgentSession({ state: "completed" });
|
|
398
|
+
saveSessionsToDb([session]);
|
|
399
|
+
|
|
400
|
+
const { deps } = makeDeps({}, {}, { shouldSucceed: false });
|
|
401
|
+
// Should not throw even if branch deletion fails
|
|
402
|
+
const output = await captureStdout(() =>
|
|
403
|
+
stopCommand("my-builder", { cleanWorktree: true }, deps),
|
|
404
|
+
);
|
|
405
|
+
expect(output).toContain("Agent stopped");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
321
409
|
describe("stopCommand stop behavior", () => {
|
|
322
410
|
test("stops a working agent (kills tmux, marks completed)", async () => {
|
|
323
411
|
const session = makeAgentSession({ state: "working" });
|
|
@@ -435,7 +523,7 @@ describe("stopCommand --clean-worktree", () => {
|
|
|
435
523
|
expect(worktreeCalls.remove[0]?.path).toBe(session.worktreePath);
|
|
436
524
|
});
|
|
437
525
|
|
|
438
|
-
test("--clean-worktree with --force passes force
|
|
526
|
+
test("--clean-worktree with --force passes force to removeWorktree (forceBranch is always false)", async () => {
|
|
439
527
|
const session = makeAgentSession({ state: "working" });
|
|
440
528
|
saveSessionsToDb([session]);
|
|
441
529
|
|
|
@@ -446,7 +534,37 @@ describe("stopCommand --clean-worktree", () => {
|
|
|
446
534
|
|
|
447
535
|
expect(worktreeCalls.remove).toHaveLength(1);
|
|
448
536
|
expect(worktreeCalls.remove[0]?.options?.force).toBe(true);
|
|
449
|
-
|
|
537
|
+
// forceBranch is always false because branch deletion is handled separately via git branch -D
|
|
538
|
+
expect(worktreeCalls.remove[0]?.options?.forceBranch).toBe(false);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("--clean-worktree also deletes the branch", async () => {
|
|
542
|
+
const session = makeAgentSession({ state: "working" });
|
|
543
|
+
saveSessionsToDb([session]);
|
|
544
|
+
|
|
545
|
+
const { deps, gitCalls } = makeDeps({ [session.tmuxSession]: true });
|
|
546
|
+
const output = await captureStdout(() =>
|
|
547
|
+
stopCommand("my-builder", { cleanWorktree: true }, deps),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
expect(gitCalls.deleteBranch).toHaveLength(1);
|
|
551
|
+
expect(gitCalls.deleteBranch[0]?.branch).toBe(session.branchName);
|
|
552
|
+
expect(output).toContain("Branch deleted");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("branch deletion failure is non-fatal (agent still stopped)", async () => {
|
|
556
|
+
const session = makeAgentSession({ state: "working" });
|
|
557
|
+
saveSessionsToDb([session]);
|
|
558
|
+
|
|
559
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true }, {}, { shouldSucceed: false });
|
|
560
|
+
const output = await captureStdout(() =>
|
|
561
|
+
stopCommand("my-builder", { cleanWorktree: true }, deps),
|
|
562
|
+
);
|
|
563
|
+
expect(output).toContain("Agent stopped");
|
|
564
|
+
const { store } = openSessionStore(overstoryDir);
|
|
565
|
+
const updated = store.getByName("my-builder");
|
|
566
|
+
store.close();
|
|
567
|
+
expect(updated?.state).toBe("completed");
|
|
450
568
|
});
|
|
451
569
|
|
|
452
570
|
test("--clean-worktree failure is non-fatal (agent still stopped, warning on stdout)", async () => {
|
|
@@ -505,15 +623,18 @@ describe("stopCommand headless agents", () => {
|
|
|
505
623
|
tmuxCalls: TmuxCallTracker;
|
|
506
624
|
procCalls: ProcessCallTracker;
|
|
507
625
|
worktreeCalls: WorktreeCallTracker;
|
|
626
|
+
gitCalls: GitCallTracker;
|
|
508
627
|
} {
|
|
509
628
|
const { tmux, calls: tmuxCalls } = makeFakeTmux({});
|
|
510
629
|
const { proc, calls: procCalls } = makeFakeProcess(pidAliveMap);
|
|
511
630
|
const { worktree, calls: worktreeCalls } = makeFakeWorktree(worktreeConfig?.shouldFail);
|
|
631
|
+
const { git, calls: gitCalls } = makeFakeGit();
|
|
512
632
|
return {
|
|
513
|
-
deps: { _tmux: tmux, _worktree: worktree, _process: proc },
|
|
633
|
+
deps: { _tmux: tmux, _worktree: worktree, _process: proc, _git: git },
|
|
514
634
|
tmuxCalls,
|
|
515
635
|
procCalls,
|
|
516
636
|
worktreeCalls,
|
|
637
|
+
gitCalls,
|
|
517
638
|
};
|
|
518
639
|
}
|
|
519
640
|
|
package/src/commands/stop.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* 2a. For TUI agents: killing its tmux session (if alive)
|
|
7
7
|
* 2b. For headless agents (tmuxSession === ''): sending SIGTERM to the process tree
|
|
8
8
|
* 3. Marking it as completed in the SessionStore
|
|
9
|
-
* 4. Optionally removing its worktree (--clean-worktree)
|
|
9
|
+
* 4. Optionally removing its worktree and branch (--clean-worktree)
|
|
10
|
+
*
|
|
11
|
+
* Completed agents: ov stop <name> without --clean-worktree throws a helpful error.
|
|
12
|
+
* With --clean-worktree, completed agents skip the kill step and proceed to cleanup.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import { join } from "node:path";
|
|
@@ -41,6 +44,24 @@ export interface StopDeps {
|
|
|
41
44
|
isAlive: (pid: number) => boolean;
|
|
42
45
|
killTree: (pid: number) => Promise<void>;
|
|
43
46
|
};
|
|
47
|
+
_git?: {
|
|
48
|
+
deleteBranch: (repoRoot: string, branch: string) => Promise<boolean>;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Delete a git branch (best-effort, non-fatal). */
|
|
53
|
+
async function deleteBranchBestEffort(repoRoot: string, branch: string): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
const proc = Bun.spawn(["git", "branch", "-D", branch], {
|
|
56
|
+
cwd: repoRoot,
|
|
57
|
+
stdout: "pipe",
|
|
58
|
+
stderr: "pipe",
|
|
59
|
+
});
|
|
60
|
+
const exitCode = await proc.exited;
|
|
61
|
+
return exitCode === 0;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
/**
|
|
@@ -48,7 +69,7 @@ export interface StopDeps {
|
|
|
48
69
|
*
|
|
49
70
|
* @param agentName - Name of the agent to stop
|
|
50
71
|
* @param opts - Command options
|
|
51
|
-
* @param deps - Optional dependency injection for testing (tmux, worktree, process)
|
|
72
|
+
* @param deps - Optional dependency injection for testing (tmux, worktree, process, git)
|
|
52
73
|
*/
|
|
53
74
|
export async function stopCommand(
|
|
54
75
|
agentName: string,
|
|
@@ -69,6 +90,7 @@ export async function stopCommand(
|
|
|
69
90
|
const tmux = deps._tmux ?? { isSessionAlive, killSession };
|
|
70
91
|
const worktree = deps._worktree ?? { remove: removeWorktree };
|
|
71
92
|
const proc = deps._process ?? { isAlive: isProcessAlive, killTree: killProcessTree };
|
|
93
|
+
const git = deps._git ?? { deleteBranch: deleteBranchBestEffort };
|
|
72
94
|
|
|
73
95
|
const cwd = process.cwd();
|
|
74
96
|
const config = await loadConfig(cwd);
|
|
@@ -82,49 +104,69 @@ export async function stopCommand(
|
|
|
82
104
|
throw new AgentError(`Agent "${agentName}" not found`, { agentName });
|
|
83
105
|
}
|
|
84
106
|
|
|
85
|
-
|
|
86
|
-
|
|
107
|
+
const isAlreadyCompleted = session.state === "completed";
|
|
108
|
+
|
|
109
|
+
// Completed agents without --clean-worktree: throw with helpful message
|
|
110
|
+
if (isAlreadyCompleted && !cleanWorktree) {
|
|
111
|
+
throw new AgentError(
|
|
112
|
+
`Agent "${agentName}" is already completed. Use --clean-worktree to remove its worktree.`,
|
|
113
|
+
{ agentName },
|
|
114
|
+
);
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
const isZombie = session.state === "zombie";
|
|
90
|
-
|
|
91
118
|
const isHeadless = session.tmuxSession === "" && session.pid !== null;
|
|
92
119
|
|
|
93
120
|
let tmuxKilled = false;
|
|
94
121
|
let pidKilled = false;
|
|
95
122
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await tmux.
|
|
108
|
-
|
|
123
|
+
// Skip kill operations for already-completed agents (process/tmux already gone)
|
|
124
|
+
if (!isAlreadyCompleted) {
|
|
125
|
+
if (isHeadless && session.pid !== null) {
|
|
126
|
+
// Headless agent: kill via process tree instead of tmux
|
|
127
|
+
const alive = proc.isAlive(session.pid);
|
|
128
|
+
if (alive) {
|
|
129
|
+
await proc.killTree(session.pid);
|
|
130
|
+
pidKilled = true;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
// TUI agent: kill via tmux session
|
|
134
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
135
|
+
if (alive) {
|
|
136
|
+
await tmux.killSession(session.tmuxSession);
|
|
137
|
+
tmuxKilled = true;
|
|
138
|
+
}
|
|
109
139
|
}
|
|
110
|
-
}
|
|
111
140
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
// Mark session as completed
|
|
142
|
+
store.updateState(agentName, "completed");
|
|
143
|
+
store.updateLastActivity(agentName);
|
|
144
|
+
}
|
|
115
145
|
|
|
116
|
-
// Optionally remove worktree (best-effort, non-fatal)
|
|
146
|
+
// Optionally remove worktree and branch (best-effort, non-fatal)
|
|
117
147
|
let worktreeRemoved = false;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
let branchDeleted = false;
|
|
149
|
+
if (cleanWorktree) {
|
|
150
|
+
if (session.worktreePath) {
|
|
151
|
+
try {
|
|
152
|
+
await worktree.remove(projectRoot, session.worktreePath, {
|
|
153
|
+
force,
|
|
154
|
+
forceBranch: false,
|
|
155
|
+
});
|
|
156
|
+
worktreeRemoved = true;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
if (!json) printWarning("Failed to remove worktree", msg);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Delete the branch after removing the worktree (best-effort, non-fatal)
|
|
164
|
+
if (session.branchName) {
|
|
165
|
+
try {
|
|
166
|
+
branchDeleted = await git.deleteBranch(projectRoot, session.branchName);
|
|
167
|
+
} catch {
|
|
168
|
+
branchDeleted = false;
|
|
169
|
+
}
|
|
128
170
|
}
|
|
129
171
|
}
|
|
130
172
|
|
|
@@ -137,30 +179,40 @@ export async function stopCommand(
|
|
|
137
179
|
tmuxKilled,
|
|
138
180
|
pidKilled,
|
|
139
181
|
worktreeRemoved,
|
|
182
|
+
branchDeleted,
|
|
140
183
|
force,
|
|
141
184
|
wasZombie: isZombie,
|
|
185
|
+
wasCompleted: isAlreadyCompleted,
|
|
142
186
|
});
|
|
143
187
|
} else {
|
|
144
188
|
printSuccess("Agent stopped", agentName);
|
|
145
|
-
if (
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (tmuxKilled) {
|
|
153
|
-
process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
|
|
189
|
+
if (!isAlreadyCompleted) {
|
|
190
|
+
if (isHeadless) {
|
|
191
|
+
if (pidKilled) {
|
|
192
|
+
process.stdout.write(` Process tree killed: PID ${session.pid}\n`);
|
|
193
|
+
} else {
|
|
194
|
+
process.stdout.write(` Process was already dead (PID ${session.pid})\n`);
|
|
195
|
+
}
|
|
154
196
|
} else {
|
|
155
|
-
|
|
197
|
+
if (tmuxKilled) {
|
|
198
|
+
process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
|
|
199
|
+
} else {
|
|
200
|
+
process.stdout.write(` Tmux session was already dead\n`);
|
|
201
|
+
}
|
|
156
202
|
}
|
|
157
203
|
}
|
|
158
204
|
if (isZombie) {
|
|
159
205
|
process.stdout.write(` Zombie agent cleaned up (state → completed)\n`);
|
|
160
206
|
}
|
|
207
|
+
if (isAlreadyCompleted) {
|
|
208
|
+
process.stdout.write(` Agent was already completed (skipped kill)\n`);
|
|
209
|
+
}
|
|
161
210
|
if (cleanWorktree && worktreeRemoved) {
|
|
162
211
|
process.stdout.write(` Worktree removed: ${session.worktreePath}\n`);
|
|
163
212
|
}
|
|
213
|
+
if (cleanWorktree && branchDeleted) {
|
|
214
|
+
process.stdout.write(` Branch deleted: ${session.branchName}\n`);
|
|
215
|
+
}
|
|
164
216
|
}
|
|
165
217
|
} finally {
|
|
166
218
|
store.close();
|
package/src/commands/watch.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from "node:path";
|
|
|
10
10
|
import { Command } from "commander";
|
|
11
11
|
import { loadConfig } from "../config.ts";
|
|
12
12
|
import { OverstoryError } from "../errors.ts";
|
|
13
|
+
import { jsonOutput } from "../json.ts";
|
|
13
14
|
import { printError, printHint, printSuccess } from "../logging/color.ts";
|
|
14
15
|
import type { HealthCheck } from "../types.ts";
|
|
15
16
|
import { startDaemon } from "../watchdog/daemon.ts";
|
|
@@ -115,7 +116,11 @@ async function resolveOverstoryBin(): Promise<string> {
|
|
|
115
116
|
/**
|
|
116
117
|
* Core implementation for the watch command.
|
|
117
118
|
*/
|
|
118
|
-
async function runWatch(opts: {
|
|
119
|
+
async function runWatch(opts: {
|
|
120
|
+
interval?: string;
|
|
121
|
+
background?: boolean;
|
|
122
|
+
json?: boolean;
|
|
123
|
+
}): Promise<void> {
|
|
119
124
|
const cwd = process.cwd();
|
|
120
125
|
const config = await loadConfig(cwd);
|
|
121
126
|
|
|
@@ -127,13 +132,19 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
|
|
|
127
132
|
const zombieThresholdMs = config.watchdog.zombieThresholdMs;
|
|
128
133
|
const pidFilePath = join(config.project.root, ".overstory", "watchdog.pid");
|
|
129
134
|
|
|
135
|
+
const useJson = opts.json ?? false;
|
|
136
|
+
|
|
130
137
|
if (opts.background) {
|
|
131
138
|
// Check if a watchdog is already running
|
|
132
139
|
const existingPid = await readPidFile(pidFilePath);
|
|
133
140
|
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
if (useJson) {
|
|
142
|
+
jsonOutput("watch", { running: true, pid: existingPid, error: "Watchdog already running" });
|
|
143
|
+
} else {
|
|
144
|
+
printError(
|
|
145
|
+
`Watchdog already running (PID: ${existingPid}). Kill it first or remove ${pidFilePath}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
137
148
|
process.exitCode = 1;
|
|
138
149
|
return;
|
|
139
150
|
}
|
|
@@ -168,14 +179,22 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
|
|
|
168
179
|
// Write PID file for later cleanup
|
|
169
180
|
await writePidFile(pidFilePath, childPid);
|
|
170
181
|
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
if (useJson) {
|
|
183
|
+
jsonOutput("watch", { pid: childPid, intervalMs, pidFile: pidFilePath });
|
|
184
|
+
} else {
|
|
185
|
+
printSuccess("Watchdog started in background", `PID: ${childPid}, interval: ${intervalMs}ms`);
|
|
186
|
+
printHint(`PID file: ${pidFilePath}`);
|
|
187
|
+
}
|
|
173
188
|
return;
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
// Foreground mode: show real-time health checks
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
if (useJson) {
|
|
193
|
+
jsonOutput("watch", { pid: process.pid, intervalMs, mode: "foreground" });
|
|
194
|
+
} else {
|
|
195
|
+
printSuccess("Watchdog running", `interval: ${intervalMs}ms`);
|
|
196
|
+
printHint("Press Ctrl+C to stop.");
|
|
197
|
+
}
|
|
179
198
|
|
|
180
199
|
// Write PID file so `--background` check and external tools can find us
|
|
181
200
|
await writePidFile(pidFilePath, process.pid);
|
|
@@ -212,7 +231,8 @@ export function createWatchCommand(): Command {
|
|
|
212
231
|
.description("Start Tier 0 mechanical watchdog daemon")
|
|
213
232
|
.option("--interval <ms>", "Health check interval in milliseconds")
|
|
214
233
|
.option("--background", "Daemonize (run in background)")
|
|
215
|
-
.
|
|
234
|
+
.option("--json", "Output as JSON")
|
|
235
|
+
.action(async (opts: { interval?: string; background?: boolean; json?: boolean }) => {
|
|
216
236
|
await runWatch(opts);
|
|
217
237
|
});
|
|
218
238
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -1088,6 +1088,71 @@ describe("projectRootOverride", () => {
|
|
|
1088
1088
|
});
|
|
1089
1089
|
});
|
|
1090
1090
|
|
|
1091
|
+
describe("coordinator.exitTriggers", () => {
|
|
1092
|
+
let tempDir: string;
|
|
1093
|
+
|
|
1094
|
+
beforeEach(async () => {
|
|
1095
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-test-"));
|
|
1096
|
+
const { mkdir } = await import("node:fs/promises");
|
|
1097
|
+
await mkdir(join(tempDir, ".overstory"), { recursive: true });
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
afterEach(async () => {
|
|
1101
|
+
await cleanupTempDir(tempDir);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
async function writeConfig(yaml: string): Promise<void> {
|
|
1105
|
+
await Bun.write(join(tempDir, ".overstory", "config.yaml"), yaml);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
test("defaults all exitTriggers to false", async () => {
|
|
1109
|
+
const config = await loadConfig(tempDir);
|
|
1110
|
+
expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(false);
|
|
1111
|
+
expect(config.coordinator?.exitTriggers.taskTrackerEmpty).toBe(false);
|
|
1112
|
+
expect(config.coordinator?.exitTriggers.onShutdownSignal).toBe(false);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test("parses coordinator.exitTriggers from config.yaml", async () => {
|
|
1116
|
+
await writeConfig(`
|
|
1117
|
+
coordinator:
|
|
1118
|
+
exitTriggers:
|
|
1119
|
+
allAgentsDone: true
|
|
1120
|
+
taskTrackerEmpty: true
|
|
1121
|
+
onShutdownSignal: false
|
|
1122
|
+
`);
|
|
1123
|
+
const config = await loadConfig(tempDir);
|
|
1124
|
+
expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(true);
|
|
1125
|
+
expect(config.coordinator?.exitTriggers.taskTrackerEmpty).toBe(true);
|
|
1126
|
+
expect(config.coordinator?.exitTriggers.onShutdownSignal).toBe(false);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
test("partial exitTriggers override keeps unset values at default (false)", async () => {
|
|
1130
|
+
await writeConfig(`
|
|
1131
|
+
coordinator:
|
|
1132
|
+
exitTriggers:
|
|
1133
|
+
onShutdownSignal: true
|
|
1134
|
+
`);
|
|
1135
|
+
const config = await loadConfig(tempDir);
|
|
1136
|
+
expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(false);
|
|
1137
|
+
expect(config.coordinator?.exitTriggers.taskTrackerEmpty).toBe(false);
|
|
1138
|
+
expect(config.coordinator?.exitTriggers.onShutdownSignal).toBe(true);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
test("config.local.yaml can override exitTriggers", async () => {
|
|
1142
|
+
await writeConfig(`
|
|
1143
|
+
coordinator:
|
|
1144
|
+
exitTriggers:
|
|
1145
|
+
allAgentsDone: false
|
|
1146
|
+
`);
|
|
1147
|
+
await Bun.write(
|
|
1148
|
+
join(tempDir, ".overstory", "config.local.yaml"),
|
|
1149
|
+
`coordinator:\n exitTriggers:\n allAgentsDone: true\n`,
|
|
1150
|
+
);
|
|
1151
|
+
const config = await loadConfig(tempDir);
|
|
1152
|
+
expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(true);
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1091
1156
|
describe("DEFAULT_CONFIG", () => {
|
|
1092
1157
|
test("has all required top-level keys", () => {
|
|
1093
1158
|
expect(DEFAULT_CONFIG.project).toBeDefined();
|
|
@@ -1126,6 +1191,13 @@ describe("DEFAULT_CONFIG", () => {
|
|
|
1126
1191
|
expect(DEFAULT_CONFIG.project.qualityGates?.[2]?.command).toBe("bun run typecheck");
|
|
1127
1192
|
});
|
|
1128
1193
|
|
|
1194
|
+
test("has coordinator with exitTriggers defaulting to false", () => {
|
|
1195
|
+
expect(DEFAULT_CONFIG.coordinator).toBeDefined();
|
|
1196
|
+
expect(DEFAULT_CONFIG.coordinator?.exitTriggers.allAgentsDone).toBe(false);
|
|
1197
|
+
expect(DEFAULT_CONFIG.coordinator?.exitTriggers.taskTrackerEmpty).toBe(false);
|
|
1198
|
+
expect(DEFAULT_CONFIG.coordinator?.exitTriggers.onShutdownSignal).toBe(false);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1129
1201
|
test("DEFAULT_QUALITY_GATES matches the project default gates", () => {
|
|
1130
1202
|
expect(DEFAULT_QUALITY_GATES).toHaveLength(3);
|
|
1131
1203
|
expect(DEFAULT_QUALITY_GATES[0]?.name).toBe("Tests");
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { dirname, join, resolve } from "node:path";
|
|
2
2
|
import { ConfigError, ValidationError } from "./errors.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
CoordinatorExitTriggers,
|
|
5
|
+
OverstoryConfig,
|
|
6
|
+
QualityGate,
|
|
7
|
+
TaskTrackerBackend,
|
|
8
|
+
} from "./types.ts";
|
|
4
9
|
|
|
5
10
|
// Module-level project root override (set by --project global flag)
|
|
6
11
|
let _projectRootOverride: string | undefined;
|
|
@@ -83,6 +88,13 @@ export const DEFAULT_CONFIG: OverstoryConfig = {
|
|
|
83
88
|
zombieThresholdMs: 600_000, // 10 minutes
|
|
84
89
|
nudgeIntervalMs: 60_000, // 1 minute between progressive nudge stages
|
|
85
90
|
},
|
|
91
|
+
coordinator: {
|
|
92
|
+
exitTriggers: {
|
|
93
|
+
allAgentsDone: false,
|
|
94
|
+
taskTrackerEmpty: false,
|
|
95
|
+
onShutdownSignal: false,
|
|
96
|
+
} as CoordinatorExitTriggers,
|
|
97
|
+
},
|
|
86
98
|
models: {},
|
|
87
99
|
logging: {
|
|
88
100
|
verbose: false,
|
|
@@ -663,6 +675,19 @@ function validateConfig(config: OverstoryConfig): void {
|
|
|
663
675
|
}
|
|
664
676
|
}
|
|
665
677
|
|
|
678
|
+
// coordinator.exitTriggers: validate all three flags are booleans if present
|
|
679
|
+
if (config.coordinator?.exitTriggers !== undefined) {
|
|
680
|
+
const et = config.coordinator.exitTriggers;
|
|
681
|
+
for (const key of ["allAgentsDone", "taskTrackerEmpty", "onShutdownSignal"] as const) {
|
|
682
|
+
if (typeof et[key] !== "boolean") {
|
|
683
|
+
throw new ValidationError(`coordinator.exitTriggers.${key} must be a boolean`, {
|
|
684
|
+
field: `coordinator.exitTriggers.${key}`,
|
|
685
|
+
value: et[key],
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
666
691
|
// runtime.default must be a string if present
|
|
667
692
|
if (config.runtime !== undefined && typeof config.runtime.default !== "string") {
|
|
668
693
|
process.stderr.write(
|
package/src/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
|
|
|
49
49
|
import { jsonError } from "./json.ts";
|
|
50
50
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
51
51
|
|
|
52
|
-
export const VERSION = "0.8.
|
|
52
|
+
export const VERSION = "0.8.6";
|
|
53
53
|
|
|
54
54
|
const rawArgs = process.argv.slice(2);
|
|
55
55
|
|
|
@@ -145,6 +145,7 @@ program
|
|
|
145
145
|
.name("ov")
|
|
146
146
|
.description("Multi-agent orchestration for Claude Code")
|
|
147
147
|
.version(VERSION, "-v, --version", "Print version")
|
|
148
|
+
.enablePositionalOptions()
|
|
148
149
|
.option("-q, --quiet", "Suppress non-error output")
|
|
149
150
|
.option("--json", "JSON output")
|
|
150
151
|
.option("--verbose", "Verbose output")
|
|
@@ -298,6 +299,7 @@ specCmd
|
|
|
298
299
|
.argument("<task-id>", "Task ID for the spec file")
|
|
299
300
|
.option("--body <content>", "Spec content (or pipe via stdin)")
|
|
300
301
|
.option("--agent <name>", "Agent writing the spec (for attribution)")
|
|
302
|
+
.option("--json", "Output as JSON")
|
|
301
303
|
.action(async (taskId, opts) => {
|
|
302
304
|
await specWriteCommand(taskId, opts);
|
|
303
305
|
});
|
|
@@ -307,6 +309,7 @@ program
|
|
|
307
309
|
.description("Load context for orchestrator/agent")
|
|
308
310
|
.option("--agent <name>", "Prime for a specific agent")
|
|
309
311
|
.option("--compact", "Output reduced context (for PreCompact hook)")
|
|
312
|
+
.option("--json", "Output as JSON")
|
|
310
313
|
.action(async (opts) => {
|
|
311
314
|
await primeCommand(opts);
|
|
312
315
|
});
|