@os-eco/overstory-cli 0.8.4 → 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.
Files changed (43) hide show
  1. package/README.md +4 -2
  2. package/agents/coordinator.md +52 -4
  3. package/package.json +1 -1
  4. package/src/agents/manifest.test.ts +33 -8
  5. package/src/agents/manifest.ts +4 -3
  6. package/src/commands/clean.test.ts +136 -0
  7. package/src/commands/clean.ts +198 -4
  8. package/src/commands/coordinator.test.ts +420 -1
  9. package/src/commands/coordinator.ts +173 -1
  10. package/src/commands/init.test.ts +137 -0
  11. package/src/commands/init.ts +57 -1
  12. package/src/commands/inspect.test.ts +398 -1
  13. package/src/commands/inspect.ts +234 -0
  14. package/src/commands/log.test.ts +10 -11
  15. package/src/commands/log.ts +31 -32
  16. package/src/commands/prime.ts +30 -5
  17. package/src/commands/sling.ts +312 -322
  18. package/src/commands/spec.ts +8 -2
  19. package/src/commands/stop.test.ts +127 -6
  20. package/src/commands/stop.ts +95 -43
  21. package/src/commands/watch.ts +29 -9
  22. package/src/config.test.ts +72 -0
  23. package/src/config.ts +26 -1
  24. package/src/events/tailer.test.ts +461 -0
  25. package/src/events/tailer.ts +235 -0
  26. package/src/index.ts +4 -1
  27. package/src/merge/resolver.test.ts +243 -19
  28. package/src/merge/resolver.ts +235 -95
  29. package/src/runtimes/claude.test.ts +1 -1
  30. package/src/runtimes/opencode.test.ts +325 -0
  31. package/src/runtimes/opencode.ts +185 -0
  32. package/src/runtimes/pi.test.ts +119 -2
  33. package/src/runtimes/pi.ts +61 -12
  34. package/src/runtimes/registry.test.ts +21 -1
  35. package/src/runtimes/registry.ts +3 -0
  36. package/src/runtimes/sapling.test.ts +30 -0
  37. package/src/runtimes/sapling.ts +27 -24
  38. package/src/runtimes/types.ts +2 -2
  39. package/src/types.ts +19 -0
  40. package/src/watchdog/daemon.test.ts +257 -0
  41. package/src/watchdog/daemon.ts +123 -23
  42. package/src/worktree/manager.test.ts +65 -1
  43. package/src/worktree/manager.ts +36 -0
@@ -17,13 +17,14 @@ import { join } from "node:path";
17
17
  import { AgentError, ValidationError } from "../errors.ts";
18
18
  import { createMailStore } from "../mail/store.ts";
19
19
  import { openSessionStore } from "../sessions/compat.ts";
20
- import { createRunStore } from "../sessions/store.ts";
20
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
21
21
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
22
22
  import type { AgentSession } from "../types.ts";
23
23
  import {
24
24
  askCoordinator,
25
25
  buildCoordinatorBeacon,
26
26
  type CoordinatorDeps,
27
+ checkComplete,
27
28
  coordinatorCommand,
28
29
  createCoordinatorCommand,
29
30
  resolveAttach,
@@ -2104,3 +2105,421 @@ describe("askCoordinator", () => {
2104
2105
  expect(subcommandNames).toContain("ask");
2105
2106
  });
2106
2107
  });
2108
+
2109
+ // ─── checkComplete ─────────────────────────────────────────────────────────
2110
+
2111
+ describe("checkComplete", () => {
2112
+ test("all triggers disabled → complete: false", async () => {
2113
+ // Default config has no coordinator section → all triggers default to false
2114
+ const result = await checkComplete({ json: false });
2115
+ expect(result.complete).toBe(false);
2116
+ expect(result.triggers.allAgentsDone.enabled).toBe(false);
2117
+ expect(result.triggers.taskTrackerEmpty.enabled).toBe(false);
2118
+ expect(result.triggers.onShutdownSignal.enabled).toBe(false);
2119
+ });
2120
+
2121
+ test("allAgentsDone met when all non-coordinator agents completed", async () => {
2122
+ // Enable allAgentsDone in config
2123
+ await Bun.write(
2124
+ join(overstoryDir, "config.yaml"),
2125
+ [
2126
+ "project:",
2127
+ " name: test-project",
2128
+ ` root: ${tempDir}`,
2129
+ " canonicalBranch: main",
2130
+ "coordinator:",
2131
+ " exitTriggers:",
2132
+ " allAgentsDone: true",
2133
+ " taskTrackerEmpty: false",
2134
+ " onShutdownSignal: false",
2135
+ ].join("\n"),
2136
+ );
2137
+
2138
+ // Write current-run.txt
2139
+ const runId = `run-${Date.now()}`;
2140
+ await Bun.write(join(overstoryDir, "current-run.txt"), runId);
2141
+
2142
+ // Create sessions.db with two completed agents
2143
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
2144
+ try {
2145
+ const base: AgentSession = {
2146
+ id: "s1",
2147
+ agentName: "builder-1",
2148
+ capability: "builder",
2149
+ worktreePath: tempDir,
2150
+ branchName: "feat/x",
2151
+ taskId: "t1",
2152
+ tmuxSession: "tmux-1",
2153
+ state: "completed",
2154
+ pid: null,
2155
+ parentAgent: "coordinator",
2156
+ depth: 1,
2157
+ runId,
2158
+ startedAt: new Date().toISOString(),
2159
+ lastActivity: new Date().toISOString(),
2160
+ escalationLevel: 0,
2161
+ stalledSince: null,
2162
+ transcriptPath: null,
2163
+ };
2164
+ store.upsert(base);
2165
+ store.upsert({ ...base, id: "s2", agentName: "builder-2" });
2166
+ } finally {
2167
+ store.close();
2168
+ }
2169
+
2170
+ const result = await checkComplete({ json: false });
2171
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2172
+ expect(result.triggers.allAgentsDone.met).toBe(true);
2173
+ expect(result.complete).toBe(true);
2174
+ });
2175
+
2176
+ test("allAgentsDone not met when agents still working", async () => {
2177
+ await Bun.write(
2178
+ join(overstoryDir, "config.yaml"),
2179
+ [
2180
+ "project:",
2181
+ " name: test-project",
2182
+ ` root: ${tempDir}`,
2183
+ " canonicalBranch: main",
2184
+ "coordinator:",
2185
+ " exitTriggers:",
2186
+ " allAgentsDone: true",
2187
+ " taskTrackerEmpty: false",
2188
+ " onShutdownSignal: false",
2189
+ ].join("\n"),
2190
+ );
2191
+
2192
+ const runId = `run-${Date.now()}`;
2193
+ await Bun.write(join(overstoryDir, "current-run.txt"), runId);
2194
+
2195
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
2196
+ try {
2197
+ const session: AgentSession = {
2198
+ id: "s1",
2199
+ agentName: "builder-1",
2200
+ capability: "builder",
2201
+ worktreePath: tempDir,
2202
+ branchName: "feat/x",
2203
+ taskId: "t1",
2204
+ tmuxSession: "tmux-1",
2205
+ state: "working",
2206
+ pid: null,
2207
+ parentAgent: "coordinator",
2208
+ depth: 1,
2209
+ runId,
2210
+ startedAt: new Date().toISOString(),
2211
+ lastActivity: new Date().toISOString(),
2212
+ escalationLevel: 0,
2213
+ stalledSince: null,
2214
+ transcriptPath: null,
2215
+ };
2216
+ store.upsert(session);
2217
+ } finally {
2218
+ store.close();
2219
+ }
2220
+
2221
+ const result = await checkComplete({ json: false });
2222
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2223
+ expect(result.triggers.allAgentsDone.met).toBe(false);
2224
+ expect(result.complete).toBe(false);
2225
+ });
2226
+
2227
+ test("allAgentsDone filters out coordinator session", async () => {
2228
+ await Bun.write(
2229
+ join(overstoryDir, "config.yaml"),
2230
+ [
2231
+ "project:",
2232
+ " name: test-project",
2233
+ ` root: ${tempDir}`,
2234
+ " canonicalBranch: main",
2235
+ "coordinator:",
2236
+ " exitTriggers:",
2237
+ " allAgentsDone: true",
2238
+ " taskTrackerEmpty: false",
2239
+ " onShutdownSignal: false",
2240
+ ].join("\n"),
2241
+ );
2242
+
2243
+ const runId = `run-${Date.now()}`;
2244
+ await Bun.write(join(overstoryDir, "current-run.txt"), runId);
2245
+
2246
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
2247
+ try {
2248
+ // coordinator session (should be excluded)
2249
+ store.upsert({
2250
+ id: "coord",
2251
+ agentName: "coordinator",
2252
+ capability: "coordinator",
2253
+ worktreePath: tempDir,
2254
+ branchName: "main",
2255
+ taskId: "",
2256
+ tmuxSession: "tmux-coord",
2257
+ state: "working",
2258
+ pid: null,
2259
+ parentAgent: null,
2260
+ depth: 0,
2261
+ runId,
2262
+ startedAt: new Date().toISOString(),
2263
+ lastActivity: new Date().toISOString(),
2264
+ escalationLevel: 0,
2265
+ stalledSince: null,
2266
+ transcriptPath: null,
2267
+ });
2268
+ // worker session that is completed
2269
+ store.upsert({
2270
+ id: "worker",
2271
+ agentName: "builder-1",
2272
+ capability: "builder",
2273
+ worktreePath: tempDir,
2274
+ branchName: "feat/x",
2275
+ taskId: "t1",
2276
+ tmuxSession: "tmux-w",
2277
+ state: "completed",
2278
+ pid: null,
2279
+ parentAgent: "coordinator",
2280
+ depth: 1,
2281
+ runId,
2282
+ startedAt: new Date().toISOString(),
2283
+ lastActivity: new Date().toISOString(),
2284
+ escalationLevel: 0,
2285
+ stalledSince: null,
2286
+ transcriptPath: null,
2287
+ });
2288
+ } finally {
2289
+ store.close();
2290
+ }
2291
+
2292
+ const result = await checkComplete({ json: false });
2293
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2294
+ // coordinator is filtered out; only the builder counts → all done
2295
+ expect(result.triggers.allAgentsDone.met).toBe(true);
2296
+ expect(result.complete).toBe(true);
2297
+ });
2298
+
2299
+ test("onShutdownSignal met when shutdown mail exists", async () => {
2300
+ await Bun.write(
2301
+ join(overstoryDir, "config.yaml"),
2302
+ [
2303
+ "project:",
2304
+ " name: test-project",
2305
+ ` root: ${tempDir}`,
2306
+ " canonicalBranch: main",
2307
+ "coordinator:",
2308
+ " exitTriggers:",
2309
+ " allAgentsDone: false",
2310
+ " taskTrackerEmpty: false",
2311
+ " onShutdownSignal: true",
2312
+ ].join("\n"),
2313
+ );
2314
+
2315
+ // Insert a shutdown message into mail.db
2316
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
2317
+ try {
2318
+ mailStore.insert({
2319
+ id: "",
2320
+ from: "greenhouse",
2321
+ to: "coordinator",
2322
+ subject: "shutdown",
2323
+ body: "All work done, please shutdown",
2324
+ type: "status",
2325
+ priority: "normal",
2326
+ threadId: null,
2327
+ payload: null,
2328
+ });
2329
+ } finally {
2330
+ mailStore.close();
2331
+ }
2332
+
2333
+ const result = await checkComplete({ json: false });
2334
+ expect(result.triggers.onShutdownSignal.enabled).toBe(true);
2335
+ expect(result.triggers.onShutdownSignal.met).toBe(true);
2336
+ expect(result.complete).toBe(true);
2337
+ });
2338
+
2339
+ test("overall complete false when only one of two enabled triggers is met", async () => {
2340
+ // Enable allAgentsDone + onShutdownSignal; satisfy only onShutdownSignal
2341
+ await Bun.write(
2342
+ join(overstoryDir, "config.yaml"),
2343
+ [
2344
+ "project:",
2345
+ " name: test-project",
2346
+ ` root: ${tempDir}`,
2347
+ " canonicalBranch: main",
2348
+ "coordinator:",
2349
+ " exitTriggers:",
2350
+ " allAgentsDone: true",
2351
+ " taskTrackerEmpty: false",
2352
+ " onShutdownSignal: true",
2353
+ ].join("\n"),
2354
+ );
2355
+
2356
+ // Write current-run.txt but no sessions → allAgentsDone not met (empty run)
2357
+ const runId = `run-${Date.now()}`;
2358
+ await Bun.write(join(overstoryDir, "current-run.txt"), runId);
2359
+ // Sessions DB will be created empty — no agents → allAgentsDone.met = false (length === 0)
2360
+
2361
+ // Insert shutdown mail so onShutdownSignal is met
2362
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
2363
+ try {
2364
+ mailStore.insert({
2365
+ id: "",
2366
+ from: "operator",
2367
+ to: "coordinator",
2368
+ subject: "shutdown now",
2369
+ body: "Please shutdown",
2370
+ type: "status",
2371
+ priority: "normal",
2372
+ threadId: null,
2373
+ payload: null,
2374
+ });
2375
+ } finally {
2376
+ mailStore.close();
2377
+ }
2378
+
2379
+ const result = await checkComplete({ json: false });
2380
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2381
+ expect(result.triggers.allAgentsDone.met).toBe(false);
2382
+ expect(result.triggers.onShutdownSignal.enabled).toBe(true);
2383
+ expect(result.triggers.onShutdownSignal.met).toBe(true);
2384
+ // Both must be met → false
2385
+ expect(result.complete).toBe(false);
2386
+ });
2387
+
2388
+ test("allAgentsDone false when merge queue has pending branches", async () => {
2389
+ await Bun.write(
2390
+ join(overstoryDir, "config.yaml"),
2391
+ [
2392
+ "project:",
2393
+ " name: test-project",
2394
+ ` root: ${tempDir}`,
2395
+ " canonicalBranch: main",
2396
+ "coordinator:",
2397
+ " exitTriggers:",
2398
+ " allAgentsDone: true",
2399
+ " taskTrackerEmpty: false",
2400
+ " onShutdownSignal: false",
2401
+ ].join("\n"),
2402
+ );
2403
+
2404
+ const runId = `run-${Date.now()}`;
2405
+ await Bun.write(join(overstoryDir, "current-run.txt"), runId);
2406
+
2407
+ // All agent sessions completed
2408
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
2409
+ try {
2410
+ store.upsert({
2411
+ id: "s1",
2412
+ agentName: "lead-1",
2413
+ capability: "lead",
2414
+ worktreePath: tempDir,
2415
+ branchName: "overstory/lead-1/task-1",
2416
+ taskId: "task-1",
2417
+ tmuxSession: "tmux-1",
2418
+ state: "completed",
2419
+ pid: null,
2420
+ parentAgent: "coordinator",
2421
+ depth: 1,
2422
+ runId,
2423
+ startedAt: new Date().toISOString(),
2424
+ lastActivity: new Date().toISOString(),
2425
+ escalationLevel: 0,
2426
+ stalledSince: null,
2427
+ transcriptPath: null,
2428
+ });
2429
+ } finally {
2430
+ store.close();
2431
+ }
2432
+
2433
+ // Merge queue has a pending entry — lead branch not yet merged
2434
+ const { createMergeQueue } = await import("../merge/queue.ts");
2435
+ const queue = createMergeQueue(join(overstoryDir, "merge-queue.db"));
2436
+ try {
2437
+ queue.enqueue({
2438
+ branchName: "overstory/lead-1/task-1",
2439
+ taskId: "task-1",
2440
+ agentName: "lead-1",
2441
+ filesModified: ["src/foo.ts"],
2442
+ });
2443
+ } finally {
2444
+ queue.close();
2445
+ }
2446
+
2447
+ const result = await checkComplete({ json: false });
2448
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2449
+ expect(result.triggers.allAgentsDone.met).toBe(false);
2450
+ expect(result.triggers.allAgentsDone.detail).toInclude("pending merge");
2451
+ expect(result.triggers.allAgentsDone.detail).toInclude("overstory/lead-1/task-1");
2452
+ expect(result.complete).toBe(false);
2453
+ });
2454
+
2455
+ test("allAgentsDone true when all agents completed and merge queue is empty", async () => {
2456
+ await Bun.write(
2457
+ join(overstoryDir, "config.yaml"),
2458
+ [
2459
+ "project:",
2460
+ " name: test-project",
2461
+ ` root: ${tempDir}`,
2462
+ " canonicalBranch: main",
2463
+ "coordinator:",
2464
+ " exitTriggers:",
2465
+ " allAgentsDone: true",
2466
+ " taskTrackerEmpty: false",
2467
+ " onShutdownSignal: false",
2468
+ ].join("\n"),
2469
+ );
2470
+
2471
+ const runId = `run-${Date.now()}`;
2472
+ await Bun.write(join(overstoryDir, "current-run.txt"), runId);
2473
+
2474
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
2475
+ try {
2476
+ store.upsert({
2477
+ id: "s1",
2478
+ agentName: "lead-1",
2479
+ capability: "lead",
2480
+ worktreePath: tempDir,
2481
+ branchName: "overstory/lead-1/task-1",
2482
+ taskId: "task-1",
2483
+ tmuxSession: "tmux-1",
2484
+ state: "completed",
2485
+ pid: null,
2486
+ parentAgent: "coordinator",
2487
+ depth: 1,
2488
+ runId,
2489
+ startedAt: new Date().toISOString(),
2490
+ lastActivity: new Date().toISOString(),
2491
+ escalationLevel: 0,
2492
+ stalledSince: null,
2493
+ transcriptPath: null,
2494
+ });
2495
+ } finally {
2496
+ store.close();
2497
+ }
2498
+
2499
+ // Merge queue exists but all entries are already merged (no pending)
2500
+ const { createMergeQueue } = await import("../merge/queue.ts");
2501
+ const queue = createMergeQueue(join(overstoryDir, "merge-queue.db"));
2502
+ try {
2503
+ const entry = queue.enqueue({
2504
+ branchName: "overstory/lead-1/task-1",
2505
+ taskId: "task-1",
2506
+ agentName: "lead-1",
2507
+ filesModified: ["src/foo.ts"],
2508
+ });
2509
+ queue.updateStatus(entry.branchName, "merged", "clean-merge");
2510
+ } finally {
2511
+ queue.close();
2512
+ }
2513
+
2514
+ const result = await checkComplete({ json: false });
2515
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2516
+ expect(result.triggers.allAgentsDone.met).toBe(true);
2517
+ expect(result.complete).toBe(true);
2518
+ });
2519
+
2520
+ test("command registration — createCoordinatorCommand has check-complete subcommand", () => {
2521
+ const cmd = createCoordinatorCommand({});
2522
+ const subcommandNames = cmd.commands.map((c) => c.name());
2523
+ expect(subcommandNames).toContain("check-complete");
2524
+ });
2525
+ });
@@ -25,7 +25,7 @@ import { createMailClient } from "../mail/client.ts";
25
25
  import { createMailStore } from "../mail/store.ts";
26
26
  import { getRuntime } from "../runtimes/registry.ts";
27
27
  import { openSessionStore } from "../sessions/compat.ts";
28
- import { createRunStore } from "../sessions/store.ts";
28
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
29
29
  import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
30
30
  import type { AgentSession } from "../types.ts";
31
31
  import { isProcessRunning } from "../watchdog/health.ts";
@@ -1054,6 +1054,170 @@ async function outputCoordinator(
1054
1054
  }
1055
1055
  }
1056
1056
 
1057
+ /** Per-trigger evaluation result for checkComplete. */
1058
+ export interface TriggerResult {
1059
+ enabled: boolean;
1060
+ met: boolean;
1061
+ detail: string;
1062
+ }
1063
+
1064
+ /** Result of `ov coordinator check-complete`. */
1065
+ export interface CheckCompleteResult {
1066
+ complete: boolean;
1067
+ triggers: {
1068
+ allAgentsDone: TriggerResult;
1069
+ taskTrackerEmpty: TriggerResult;
1070
+ onShutdownSignal: TriggerResult;
1071
+ };
1072
+ }
1073
+
1074
+ /**
1075
+ * Evaluate configured exit triggers and return per-trigger status.
1076
+ *
1077
+ * Logic:
1078
+ * - complete = true only if ALL enabled triggers are met
1079
+ * - No enabled triggers → complete: false (safety default)
1080
+ */
1081
+ export async function checkComplete(
1082
+ opts: { json: boolean },
1083
+ deps?: CoordinatorDeps,
1084
+ ): Promise<CheckCompleteResult> {
1085
+ void deps; // reserved for future DI
1086
+
1087
+ const config = await loadConfig(process.cwd());
1088
+ const triggers = config.coordinator?.exitTriggers ?? {
1089
+ allAgentsDone: false,
1090
+ taskTrackerEmpty: false,
1091
+ onShutdownSignal: false,
1092
+ };
1093
+
1094
+ const result: CheckCompleteResult = {
1095
+ complete: false,
1096
+ triggers: {
1097
+ allAgentsDone: { enabled: triggers.allAgentsDone, met: false, detail: "" },
1098
+ taskTrackerEmpty: { enabled: triggers.taskTrackerEmpty, met: false, detail: "" },
1099
+ onShutdownSignal: { enabled: triggers.onShutdownSignal, met: false, detail: "" },
1100
+ },
1101
+ };
1102
+
1103
+ // allAgentsDone: read current-run.txt, query SessionStore
1104
+ if (triggers.allAgentsDone) {
1105
+ const runIdPath = join(config.project.root, ".overstory", "current-run.txt");
1106
+ const runIdFile = Bun.file(runIdPath);
1107
+ if (await runIdFile.exists()) {
1108
+ const runId = (await runIdFile.text()).trim();
1109
+ const sessionsDb = join(config.project.root, ".overstory", "sessions.db");
1110
+ const store = createSessionStore(sessionsDb);
1111
+ try {
1112
+ const sessions = store.getByRun(runId);
1113
+ const agentSessions = sessions.filter((s) => s.capability !== "coordinator");
1114
+ let allDone =
1115
+ agentSessions.length > 0 && agentSessions.every((s) => s.state === "completed");
1116
+ const states = agentSessions.map((s) => `${s.agentName}:${s.state}`);
1117
+
1118
+ // Also check the merge queue — agents may be "completed" but branches
1119
+ // not yet merged. This prevents premature issue closure when a builder
1120
+ // finishes but its lead hasn't merged yet (overstory-5c08).
1121
+ if (allDone) {
1122
+ const mergeQueuePath = join(config.project.root, ".overstory", "merge-queue.db");
1123
+ const mergeQueueFile = Bun.file(mergeQueuePath);
1124
+ if (await mergeQueueFile.exists()) {
1125
+ const { createMergeQueue } = await import("../merge/queue.ts");
1126
+ const queue = createMergeQueue(mergeQueuePath);
1127
+ try {
1128
+ const pending = queue.list("pending");
1129
+ if (pending.length > 0) {
1130
+ allDone = false;
1131
+ result.triggers.allAgentsDone.detail = `${pending.length} branch(es) pending merge: ${pending.map((e) => e.branchName).join(", ")}`;
1132
+ }
1133
+ } finally {
1134
+ queue.close();
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ result.triggers.allAgentsDone.met = allDone;
1140
+ if (result.triggers.allAgentsDone.detail === "") {
1141
+ result.triggers.allAgentsDone.detail = allDone
1142
+ ? `All ${agentSessions.length} agents completed`
1143
+ : states.join(", ");
1144
+ }
1145
+ } finally {
1146
+ store.close();
1147
+ }
1148
+ } else {
1149
+ result.triggers.allAgentsDone.detail = "No current run found";
1150
+ }
1151
+ }
1152
+
1153
+ // taskTrackerEmpty: shell out to tracker CLI
1154
+ if (triggers.taskTrackerEmpty) {
1155
+ try {
1156
+ const backend = await resolveBackend(config.taskTracker.backend, config.project.root);
1157
+ const cliName = trackerCliName(backend);
1158
+ const proc = Bun.spawn([cliName, "ready", "--json"], {
1159
+ cwd: config.project.root,
1160
+ stdout: "pipe",
1161
+ stderr: "pipe",
1162
+ });
1163
+ const exitCode = await proc.exited;
1164
+ const stdout = await new Response(proc.stdout).text();
1165
+ if (exitCode === 0) {
1166
+ try {
1167
+ const issues = JSON.parse(stdout.trim()) as unknown;
1168
+ const isEmpty = Array.isArray(issues) && issues.length === 0;
1169
+ result.triggers.taskTrackerEmpty.met = isEmpty;
1170
+ result.triggers.taskTrackerEmpty.detail = isEmpty
1171
+ ? "No unblocked issues"
1172
+ : `${(issues as unknown[]).length} unblocked issue(s)`;
1173
+ } catch {
1174
+ const isEmpty = stdout.trim() === "" || stdout.trim() === "[]";
1175
+ result.triggers.taskTrackerEmpty.met = isEmpty;
1176
+ result.triggers.taskTrackerEmpty.detail = isEmpty
1177
+ ? "No unblocked issues"
1178
+ : "Issues found";
1179
+ }
1180
+ } else {
1181
+ result.triggers.taskTrackerEmpty.detail = `Tracker command failed (exit ${exitCode})`;
1182
+ }
1183
+ } catch (err) {
1184
+ result.triggers.taskTrackerEmpty.detail = `Tracker error: ${err instanceof Error ? err.message : String(err)}`;
1185
+ }
1186
+ }
1187
+
1188
+ // onShutdownSignal: check mail for shutdown messages to coordinator
1189
+ if (triggers.onShutdownSignal) {
1190
+ const mailDb = join(config.project.root, ".overstory", "mail.db");
1191
+ const mailStore = createMailStore(mailDb);
1192
+ try {
1193
+ const unread = mailStore.getUnread("coordinator");
1194
+ const shutdownMsg = unread.find((m) => m.subject.toLowerCase().includes("shutdown"));
1195
+ result.triggers.onShutdownSignal.met = shutdownMsg !== undefined;
1196
+ result.triggers.onShutdownSignal.detail = shutdownMsg
1197
+ ? `Shutdown signal from ${shutdownMsg.from}: ${shutdownMsg.subject}`
1198
+ : "No shutdown signal received";
1199
+ } finally {
1200
+ mailStore.close();
1201
+ }
1202
+ }
1203
+
1204
+ // Overall: complete only if ALL enabled triggers are met
1205
+ const enabledTriggers = Object.values(result.triggers).filter((t) => t.enabled);
1206
+ result.complete = enabledTriggers.length > 0 && enabledTriggers.every((t) => t.met);
1207
+
1208
+ if (opts.json) {
1209
+ jsonOutput("coordinator check-complete", result as unknown as Record<string, unknown>);
1210
+ } else {
1211
+ for (const [name, trigger] of Object.entries(result.triggers)) {
1212
+ const status = !trigger.enabled ? "disabled" : trigger.met ? "MET" : "NOT MET";
1213
+ process.stdout.write(` ${name}: ${status} — ${trigger.detail}\n`);
1214
+ }
1215
+ process.stdout.write(`\nComplete: ${result.complete ? "YES" : "NO"}\n`);
1216
+ }
1217
+
1218
+ return result;
1219
+ }
1220
+
1057
1221
  /**
1058
1222
  * Create the Commander command for `ov coordinator`.
1059
1223
  */
@@ -1150,6 +1314,14 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1150
1314
  },
1151
1315
  );
1152
1316
 
1317
+ cmd
1318
+ .command("check-complete")
1319
+ .description("Evaluate exit triggers and report completion status")
1320
+ .option("--json", "Output as JSON")
1321
+ .action(async (opts: { json?: boolean }) => {
1322
+ await checkComplete({ json: opts.json ?? false }, deps);
1323
+ });
1324
+
1153
1325
  return cmd;
1154
1326
  }
1155
1327