@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.
@@ -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
- printSuccess("Spec written", taskId);
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 worktree. */
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
- return { deps: { _tmux: tmux, _worktree: worktree }, tmuxCalls, worktreeCalls };
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 flags to removeWorktree", async () => {
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
- expect(worktreeCalls.remove[0]?.options?.forceBranch).toBe(true);
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
 
@@ -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
- if (session.state === "completed") {
86
- throw new AgentError(`Agent "${agentName}" is already completed`, { agentName });
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
- if (isHeadless && session.pid !== null) {
97
- // Headless agent: kill via process tree instead of tmux
98
- const alive = proc.isAlive(session.pid);
99
- if (alive) {
100
- await proc.killTree(session.pid);
101
- pidKilled = true;
102
- }
103
- } else {
104
- // TUI agent: kill via tmux session
105
- const alive = await tmux.isSessionAlive(session.tmuxSession);
106
- if (alive) {
107
- await tmux.killSession(session.tmuxSession);
108
- tmuxKilled = true;
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
- // Mark session as completed
113
- store.updateState(agentName, "completed");
114
- store.updateLastActivity(agentName);
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
- if (cleanWorktree && session.worktreePath) {
119
- try {
120
- await worktree.remove(projectRoot, session.worktreePath, {
121
- force,
122
- forceBranch: force,
123
- });
124
- worktreeRemoved = true;
125
- } catch (err) {
126
- const msg = err instanceof Error ? err.message : String(err);
127
- if (!json) printWarning("Failed to remove worktree", msg);
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 (isHeadless) {
146
- if (pidKilled) {
147
- process.stdout.write(` Process tree killed: PID ${session.pid}\n`);
148
- } else {
149
- process.stdout.write(` Process was already dead (PID ${session.pid})\n`);
150
- }
151
- } else {
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
- process.stdout.write(` Tmux session was already dead\n`);
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();
@@ -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: { interval?: string; background?: boolean }): Promise<void> {
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
- printError(
135
- `Watchdog already running (PID: ${existingPid}). Kill it first or remove ${pidFilePath}`,
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
- printSuccess("Watchdog started in background", `PID: ${childPid}, interval: ${intervalMs}ms`);
172
- printHint(`PID file: ${pidFilePath}`);
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
- printSuccess("Watchdog running", `interval: ${intervalMs}ms`);
178
- printHint("Press Ctrl+C to stop.");
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
- .action(async (opts: { interval?: string; background?: boolean }) => {
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
  }
@@ -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 { OverstoryConfig, QualityGate, TaskTrackerBackend } from "./types.ts";
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.5";
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
  });