@os-eco/overstory-cli 0.8.7 → 0.9.2

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 (98) hide show
  1. package/README.md +26 -8
  2. package/agents/coordinator.md +30 -6
  3. package/agents/lead.md +11 -1
  4. package/agents/ov-co-creation.md +90 -0
  5. package/package.json +1 -1
  6. package/src/agents/hooks-deployer.test.ts +9 -1
  7. package/src/agents/hooks-deployer.ts +2 -1
  8. package/src/agents/overlay.test.ts +26 -0
  9. package/src/agents/overlay.ts +31 -4
  10. package/src/canopy/client.test.ts +107 -0
  11. package/src/canopy/client.ts +179 -0
  12. package/src/commands/agents.ts +1 -1
  13. package/src/commands/clean.test.ts +3 -0
  14. package/src/commands/clean.ts +1 -58
  15. package/src/commands/completions.test.ts +18 -6
  16. package/src/commands/completions.ts +40 -1
  17. package/src/commands/coordinator.test.ts +77 -4
  18. package/src/commands/coordinator.ts +304 -146
  19. package/src/commands/dashboard.ts +47 -10
  20. package/src/commands/discover.test.ts +288 -0
  21. package/src/commands/discover.ts +202 -0
  22. package/src/commands/doctor.ts +3 -1
  23. package/src/commands/ecosystem.test.ts +126 -1
  24. package/src/commands/ecosystem.ts +7 -53
  25. package/src/commands/feed.test.ts +117 -2
  26. package/src/commands/feed.ts +46 -30
  27. package/src/commands/group.test.ts +274 -155
  28. package/src/commands/group.ts +11 -5
  29. package/src/commands/init.test.ts +2 -1
  30. package/src/commands/init.ts +8 -0
  31. package/src/commands/log.test.ts +35 -0
  32. package/src/commands/log.ts +10 -6
  33. package/src/commands/logs.test.ts +423 -1
  34. package/src/commands/logs.ts +99 -104
  35. package/src/commands/orchestrator.ts +42 -0
  36. package/src/commands/prime.test.ts +177 -2
  37. package/src/commands/prime.ts +4 -2
  38. package/src/commands/sling.ts +23 -3
  39. package/src/commands/update.test.ts +1 -0
  40. package/src/commands/upgrade.test.ts +2 -0
  41. package/src/commands/upgrade.ts +1 -17
  42. package/src/commands/watch.test.ts +67 -1
  43. package/src/commands/watch.ts +13 -88
  44. package/src/config.test.ts +250 -0
  45. package/src/config.ts +43 -0
  46. package/src/doctor/agents.test.ts +72 -5
  47. package/src/doctor/agents.ts +10 -10
  48. package/src/doctor/consistency.test.ts +35 -0
  49. package/src/doctor/consistency.ts +7 -3
  50. package/src/doctor/dependencies.test.ts +58 -1
  51. package/src/doctor/dependencies.ts +4 -2
  52. package/src/doctor/providers.test.ts +41 -5
  53. package/src/doctor/types.ts +2 -1
  54. package/src/doctor/version.test.ts +106 -2
  55. package/src/doctor/version.ts +4 -2
  56. package/src/doctor/watchdog.test.ts +167 -0
  57. package/src/doctor/watchdog.ts +158 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +4 -2
  59. package/src/errors.test.ts +350 -0
  60. package/src/events/tailer.test.ts +25 -0
  61. package/src/events/tailer.ts +8 -1
  62. package/src/index.ts +9 -1
  63. package/src/mail/store.test.ts +110 -0
  64. package/src/mail/store.ts +2 -1
  65. package/src/runtimes/aider.test.ts +124 -0
  66. package/src/runtimes/aider.ts +147 -0
  67. package/src/runtimes/amp.test.ts +164 -0
  68. package/src/runtimes/amp.ts +154 -0
  69. package/src/runtimes/claude.test.ts +4 -2
  70. package/src/runtimes/goose.test.ts +133 -0
  71. package/src/runtimes/goose.ts +157 -0
  72. package/src/runtimes/pi-guards.ts +2 -1
  73. package/src/runtimes/pi.test.ts +9 -9
  74. package/src/runtimes/pi.ts +6 -7
  75. package/src/runtimes/registry.test.ts +1 -1
  76. package/src/runtimes/registry.ts +13 -4
  77. package/src/runtimes/sapling.ts +2 -1
  78. package/src/runtimes/types.ts +2 -2
  79. package/src/schema-consistency.test.ts +1 -0
  80. package/src/sessions/store.ts +25 -4
  81. package/src/types.ts +65 -1
  82. package/src/utils/bin.test.ts +10 -0
  83. package/src/utils/bin.ts +37 -0
  84. package/src/utils/fs.test.ts +119 -0
  85. package/src/utils/fs.ts +62 -0
  86. package/src/utils/pid.test.ts +68 -0
  87. package/src/utils/pid.ts +45 -0
  88. package/src/utils/time.test.ts +43 -0
  89. package/src/utils/time.ts +37 -0
  90. package/src/utils/version.test.ts +33 -0
  91. package/src/utils/version.ts +70 -0
  92. package/src/watchdog/daemon.test.ts +255 -1
  93. package/src/watchdog/daemon.ts +87 -9
  94. package/src/watchdog/health.test.ts +15 -1
  95. package/src/watchdog/health.ts +1 -1
  96. package/src/watchdog/triage.test.ts +49 -9
  97. package/src/watchdog/triage.ts +21 -5
  98. package/templates/overlay.md.tmpl +2 -0
@@ -9,17 +9,19 @@
9
9
  import { join } from "node:path";
10
10
  import { Command } from "commander";
11
11
  import { loadConfig } from "../config.ts";
12
- import { OverstoryError } from "../errors.ts";
13
12
  import { jsonOutput } from "../json.ts";
14
13
  import { printError, printHint, printSuccess } from "../logging/color.ts";
15
14
  import type { HealthCheck } from "../types.ts";
15
+ import { resolveOverstoryBin } from "../utils/bin.ts";
16
+ import { readPidFile, removePidFile, writePidFile } from "../utils/pid.ts";
16
17
  import { startDaemon } from "../watchdog/daemon.ts";
17
18
  import { isProcessRunning } from "../watchdog/health.ts";
18
19
 
19
20
  /**
20
21
  * Format a health check for display.
22
+ * @internal Exported for testing.
21
23
  */
22
- function formatCheck(check: HealthCheck): string {
24
+ export function formatCheck(check: HealthCheck): string {
23
25
  const actionIcon =
24
26
  check.action === "terminate"
25
27
  ? "x"
@@ -36,83 +38,6 @@ function formatCheck(check: HealthCheck): string {
36
38
  return line;
37
39
  }
38
40
 
39
- // isProcessRunning is imported from ../watchdog/health.ts (ZFC shared utility)
40
-
41
- /**
42
- * Read the PID from the watchdog PID file.
43
- * Returns null if the file doesn't exist or can't be parsed.
44
- */
45
- async function readPidFile(pidFilePath: string): Promise<number | null> {
46
- const file = Bun.file(pidFilePath);
47
- const exists = await file.exists();
48
- if (!exists) {
49
- return null;
50
- }
51
-
52
- try {
53
- const text = await file.text();
54
- const pid = Number.parseInt(text.trim(), 10);
55
- if (Number.isNaN(pid) || pid <= 0) {
56
- return null;
57
- }
58
- return pid;
59
- } catch {
60
- return null;
61
- }
62
- }
63
-
64
- /**
65
- * Write a PID to the watchdog PID file.
66
- */
67
- async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
68
- await Bun.write(pidFilePath, `${pid}\n`);
69
- }
70
-
71
- /**
72
- * Remove the watchdog PID file.
73
- */
74
- async function removePidFile(pidFilePath: string): Promise<void> {
75
- const { unlink } = await import("node:fs/promises");
76
- try {
77
- await unlink(pidFilePath);
78
- } catch {
79
- // File may already be gone — not an error
80
- }
81
- }
82
-
83
- /**
84
- * Resolve the path to the overstory binary for re-launching.
85
- * Uses `which overstory` first, then falls back to process.argv.
86
- */
87
- async function resolveOverstoryBin(): Promise<string> {
88
- try {
89
- const proc = Bun.spawn(["which", "ov"], {
90
- stdout: "pipe",
91
- stderr: "pipe",
92
- });
93
- const exitCode = await proc.exited;
94
- if (exitCode === 0) {
95
- const binPath = (await new Response(proc.stdout).text()).trim();
96
- if (binPath.length > 0) {
97
- return binPath;
98
- }
99
- }
100
- } catch {
101
- // which not available or overstory not on PATH
102
- }
103
-
104
- // Fallback: use the script that's currently running (process.argv[1])
105
- const scriptPath = process.argv[1];
106
- if (scriptPath) {
107
- return scriptPath;
108
- }
109
-
110
- throw new OverstoryError(
111
- "Cannot resolve overstory binary path for background launch",
112
- "WATCH_ERROR",
113
- );
114
- }
115
-
116
41
  /**
117
42
  * Core implementation for the watch command.
118
43
  */
@@ -213,17 +138,17 @@ async function runWatch(opts: {
213
138
  });
214
139
 
215
140
  // Keep running until interrupted
216
- process.on("SIGINT", () => {
217
- stop();
218
- // Clean up PID file on graceful shutdown
219
- removePidFile(pidFilePath).finally(() => {
220
- printSuccess("Watchdog stopped.");
221
- process.exit(0);
141
+ await new Promise<void>((resolve) => {
142
+ process.on("SIGINT", () => {
143
+ stop();
144
+ // Clean up PID file on graceful shutdown
145
+ removePidFile(pidFilePath).finally(() => {
146
+ printSuccess("Watchdog stopped.");
147
+ process.exitCode = 0;
148
+ resolve();
149
+ });
222
150
  });
223
151
  });
224
-
225
- // Block forever
226
- await new Promise(() => {});
227
152
  }
228
153
 
229
154
  export function createWatchCommand(): Command {
@@ -371,6 +371,7 @@ watchdog:
371
371
  tier0Enabled: false
372
372
  tier0IntervalMs: 20000
373
373
  tier1Enabled: true
374
+ triageTimeoutMs: 15000
374
375
  `);
375
376
 
376
377
  const config = await loadConfig(tempDir);
@@ -568,6 +569,151 @@ watchdog:
568
569
  await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
569
570
  });
570
571
 
572
+ // rpcTimeoutMs tests
573
+ test("defaults rpcTimeoutMs to 5000", async () => {
574
+ const config = await loadConfig(tempDir);
575
+ expect(config.watchdog.rpcTimeoutMs).toBe(5000);
576
+ });
577
+
578
+ test("accepts valid rpcTimeoutMs", async () => {
579
+ await writeConfig(`
580
+ watchdog:
581
+ rpcTimeoutMs: 10000
582
+ `);
583
+ const config = await loadConfig(tempDir);
584
+ expect(config.watchdog.rpcTimeoutMs).toBe(10000);
585
+ });
586
+
587
+ test("rejects rpcTimeoutMs below 1000", async () => {
588
+ await writeConfig(`
589
+ watchdog:
590
+ rpcTimeoutMs: 999
591
+ `);
592
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
593
+ });
594
+
595
+ test("rejects rpcTimeoutMs above 30000", async () => {
596
+ await writeConfig(`
597
+ watchdog:
598
+ rpcTimeoutMs: 30001
599
+ `);
600
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
601
+ });
602
+
603
+ // triageTimeoutMs tests
604
+ test("defaults triageTimeoutMs to 30000", async () => {
605
+ const config = await loadConfig(tempDir);
606
+ expect(config.watchdog.triageTimeoutMs).toBe(30000);
607
+ });
608
+
609
+ test("accepts valid triageTimeoutMs", async () => {
610
+ await writeConfig(`
611
+ watchdog:
612
+ triageTimeoutMs: 60000
613
+ `);
614
+ const config = await loadConfig(tempDir);
615
+ expect(config.watchdog.triageTimeoutMs).toBe(60000);
616
+ });
617
+
618
+ test("rejects triageTimeoutMs below 5000", async () => {
619
+ await writeConfig(`
620
+ watchdog:
621
+ triageTimeoutMs: 4999
622
+ `);
623
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
624
+ });
625
+
626
+ test("rejects triageTimeoutMs above 120000", async () => {
627
+ await writeConfig(`
628
+ watchdog:
629
+ triageTimeoutMs: 120001
630
+ `);
631
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
632
+ });
633
+
634
+ test("rejects triageTimeoutMs >= tier0IntervalMs when tier1 is enabled", async () => {
635
+ // Must include tier0Enabled to avoid deprecated-key migration that would remap tier1Enabled
636
+ await writeConfig(`
637
+ watchdog:
638
+ tier0Enabled: true
639
+ tier1Enabled: true
640
+ tier0IntervalMs: 30000
641
+ triageTimeoutMs: 30000
642
+ `);
643
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
644
+ });
645
+
646
+ test("accepts triageTimeoutMs < tier0IntervalMs when tier1 is enabled", async () => {
647
+ await writeConfig(`
648
+ watchdog:
649
+ tier0Enabled: true
650
+ tier1Enabled: true
651
+ tier0IntervalMs: 60000
652
+ triageTimeoutMs: 30000
653
+ `);
654
+ const config = await loadConfig(tempDir);
655
+ expect(config.watchdog.triageTimeoutMs).toBe(30000);
656
+ });
657
+
658
+ test("allows triageTimeoutMs >= tier0IntervalMs when tier1 is disabled", async () => {
659
+ await writeConfig(`
660
+ watchdog:
661
+ tier0Enabled: true
662
+ tier1Enabled: false
663
+ tier0IntervalMs: 30000
664
+ triageTimeoutMs: 30000
665
+ `);
666
+ const config = await loadConfig(tempDir);
667
+ expect(config.watchdog.triageTimeoutMs).toBe(30000);
668
+ });
669
+
670
+ // maxEscalationLevel tests
671
+ test("defaults maxEscalationLevel to 3", async () => {
672
+ const config = await loadConfig(tempDir);
673
+ expect(config.watchdog.maxEscalationLevel).toBe(3);
674
+ });
675
+
676
+ test("accepts valid maxEscalationLevel", async () => {
677
+ await writeConfig(`
678
+ watchdog:
679
+ maxEscalationLevel: 5
680
+ `);
681
+ const config = await loadConfig(tempDir);
682
+ expect(config.watchdog.maxEscalationLevel).toBe(5);
683
+ });
684
+
685
+ test("rejects maxEscalationLevel below 1", async () => {
686
+ await writeConfig(`
687
+ watchdog:
688
+ maxEscalationLevel: 0
689
+ `);
690
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
691
+ });
692
+
693
+ test("rejects maxEscalationLevel above 5", async () => {
694
+ await writeConfig(`
695
+ watchdog:
696
+ maxEscalationLevel: 6
697
+ `);
698
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
699
+ });
700
+
701
+ test("accepts maxEscalationLevel boundary values 1 and 5", async () => {
702
+ await writeConfig(`
703
+ watchdog:
704
+ maxEscalationLevel: 1
705
+ `);
706
+ let config = await loadConfig(tempDir);
707
+ expect(config.watchdog.maxEscalationLevel).toBe(1);
708
+
709
+ await writeConfig(`
710
+ watchdog:
711
+ maxEscalationLevel: 5
712
+ `);
713
+ config = await loadConfig(tempDir);
714
+ expect(config.watchdog.maxEscalationLevel).toBe(5);
715
+ });
716
+
571
717
  test("accepts empty models section", async () => {
572
718
  await writeConfig(`
573
719
  models:
@@ -1153,6 +1299,110 @@ coordinator:
1153
1299
  });
1154
1300
  });
1155
1301
 
1302
+ describe("YAML parser edge cases", () => {
1303
+ let tempDir: string;
1304
+
1305
+ beforeEach(async () => {
1306
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-test-"));
1307
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
1308
+ });
1309
+
1310
+ afterEach(async () => {
1311
+ await cleanupTempDir(tempDir);
1312
+ });
1313
+
1314
+ async function writeConfig(yaml: string): Promise<void> {
1315
+ await Bun.write(join(tempDir, ".overstory", "config.yaml"), yaml);
1316
+ }
1317
+
1318
+ test("inline comments are stripped from values", async () => {
1319
+ await writeConfig(`
1320
+ project:
1321
+ canonicalBranch: develop # this is a comment
1322
+ `);
1323
+ const config = await loadConfig(tempDir);
1324
+ expect(config.project.canonicalBranch).toBe("develop");
1325
+ });
1326
+
1327
+ test("quoted strings containing # are preserved (not treated as comments)", async () => {
1328
+ await writeConfig(`
1329
+ project:
1330
+ canonicalBranch: "feature#branch"
1331
+ `);
1332
+ const config = await loadConfig(tempDir);
1333
+ expect(config.project.canonicalBranch).toBe("feature#branch");
1334
+ });
1335
+
1336
+ test("single-quoted strings containing # are preserved", async () => {
1337
+ await writeConfig(`
1338
+ project:
1339
+ canonicalBranch: 'feature#branch'
1340
+ `);
1341
+ const config = await loadConfig(tempDir);
1342
+ expect(config.project.canonicalBranch).toBe("feature#branch");
1343
+ });
1344
+
1345
+ test("boolean coercion: true/True/TRUE all parse as true", async () => {
1346
+ // Test with three separate configs since they all map to the same field
1347
+ for (const val of ["true", "True", "TRUE"]) {
1348
+ await writeConfig(`mulch:\n enabled: ${val}\n`);
1349
+ const config = await loadConfig(tempDir);
1350
+ expect(config.mulch.enabled).toBe(true);
1351
+ }
1352
+ });
1353
+
1354
+ test("boolean coercion: false/False/FALSE all parse as false", async () => {
1355
+ for (const val of ["false", "False", "FALSE"]) {
1356
+ await writeConfig(`mulch:\n enabled: ${val}\n`);
1357
+ const config = await loadConfig(tempDir);
1358
+ expect(config.mulch.enabled).toBe(false);
1359
+ }
1360
+ });
1361
+
1362
+ test("yes/no are treated as plain strings, not booleans", async () => {
1363
+ // The YAML parser does NOT treat yes/no as booleans (unlike YAML 1.1)
1364
+ await writeConfig(`
1365
+ project:
1366
+ canonicalBranch: yes
1367
+ `);
1368
+ const config = await loadConfig(tempDir);
1369
+ // "yes" is a plain string, not coerced to boolean
1370
+ expect(config.project.canonicalBranch).toBe("yes");
1371
+ });
1372
+
1373
+ test("integer number coercion", async () => {
1374
+ await writeConfig(`
1375
+ agents:
1376
+ maxConcurrent: 42
1377
+ `);
1378
+ const config = await loadConfig(tempDir);
1379
+ expect(config.agents.maxConcurrent).toBe(42);
1380
+ });
1381
+
1382
+ test("float number coercion", async () => {
1383
+ // maxSessionsPerRun doesn't accept floats, but the parser itself parses them.
1384
+ // Use a field that passes validation as a number.
1385
+ await writeConfig(`
1386
+ agents:
1387
+ maxSessionsPerRun: 5
1388
+ staggerDelayMs: 1500
1389
+ `);
1390
+ const config = await loadConfig(tempDir);
1391
+ expect(config.agents.staggerDelayMs).toBe(1500);
1392
+ });
1393
+
1394
+ test("underscore-separated numbers are coerced correctly", async () => {
1395
+ await writeConfig(`
1396
+ watchdog:
1397
+ staleThresholdMs: 300_000
1398
+ zombieThresholdMs: 600_000
1399
+ `);
1400
+ const config = await loadConfig(tempDir);
1401
+ expect(config.watchdog.staleThresholdMs).toBe(300_000);
1402
+ expect(config.watchdog.zombieThresholdMs).toBe(600_000);
1403
+ });
1404
+ });
1405
+
1156
1406
  describe("DEFAULT_CONFIG", () => {
1157
1407
  test("has all required top-level keys", () => {
1158
1408
  expect(DEFAULT_CONFIG.project).toBeDefined();
package/src/config.ts CHANGED
@@ -87,6 +87,9 @@ export const DEFAULT_CONFIG: OverstoryConfig = {
87
87
  staleThresholdMs: 300_000, // 5 minutes
88
88
  zombieThresholdMs: 600_000, // 10 minutes
89
89
  nudgeIntervalMs: 60_000, // 1 minute between progressive nudge stages
90
+ rpcTimeoutMs: 5_000, // 5 seconds for RPC getState() calls
91
+ triageTimeoutMs: 30_000, // 30 seconds for Tier 1 AI triage calls
92
+ maxEscalationLevel: 3, // Maximum escalation level before termination
90
93
  },
91
94
  coordinator: {
92
95
  exitTriggers: {
@@ -590,6 +593,46 @@ function validateConfig(config: OverstoryConfig): void {
590
593
  });
591
594
  }
592
595
 
596
+ if (config.watchdog.rpcTimeoutMs !== undefined) {
597
+ if (config.watchdog.rpcTimeoutMs < 1000 || config.watchdog.rpcTimeoutMs > 30000) {
598
+ throw new ValidationError("watchdog.rpcTimeoutMs must be between 1000 and 30000", {
599
+ field: "watchdog.rpcTimeoutMs",
600
+ value: config.watchdog.rpcTimeoutMs,
601
+ });
602
+ }
603
+ }
604
+
605
+ if (config.watchdog.triageTimeoutMs !== undefined) {
606
+ if (config.watchdog.triageTimeoutMs < 5000 || config.watchdog.triageTimeoutMs > 120000) {
607
+ throw new ValidationError("watchdog.triageTimeoutMs must be between 5000 and 120000", {
608
+ field: "watchdog.triageTimeoutMs",
609
+ value: config.watchdog.triageTimeoutMs,
610
+ });
611
+ }
612
+
613
+ if (
614
+ config.watchdog.tier1Enabled &&
615
+ config.watchdog.triageTimeoutMs >= config.watchdog.tier0IntervalMs
616
+ ) {
617
+ throw new ValidationError(
618
+ "watchdog.triageTimeoutMs must be less than tier0IntervalMs when tier1 is enabled",
619
+ {
620
+ field: "watchdog.triageTimeoutMs",
621
+ value: config.watchdog.triageTimeoutMs,
622
+ },
623
+ );
624
+ }
625
+ }
626
+
627
+ if (config.watchdog.maxEscalationLevel !== undefined) {
628
+ if (config.watchdog.maxEscalationLevel < 1 || config.watchdog.maxEscalationLevel > 5) {
629
+ throw new ValidationError("watchdog.maxEscalationLevel must be between 1 and 5", {
630
+ field: "watchdog.maxEscalationLevel",
631
+ value: config.watchdog.maxEscalationLevel,
632
+ });
633
+ }
634
+ }
635
+
593
636
  // mulch.primeFormat must be one of the valid options
594
637
  const validFormats = ["markdown", "xml", "json"] as const;
595
638
  if (!validFormats.includes(config.mulch.primeFormat as (typeof validFormats)[number])) {
@@ -124,13 +124,41 @@ describe("checkAgents", () => {
124
124
  expect(parseCheck?.status).toBe("pass");
125
125
  });
126
126
 
127
- test("fails when agent has invalid model", async () => {
127
+ test("passes when agent uses a non-empty model string", async () => {
128
128
  const manifest = {
129
129
  version: "1.0",
130
130
  agents: {
131
131
  scout: {
132
132
  file: "scout.md",
133
- model: "invalid-model",
133
+ model: "gpt-5-4",
134
+ tools: ["Read"],
135
+ capabilities: ["explore"],
136
+ canSpawn: false,
137
+ constraints: [],
138
+ },
139
+ },
140
+ capabilityIndex: {
141
+ explore: ["scout"],
142
+ },
143
+ };
144
+
145
+ await mkdir(join(overstoryDir, "agent-defs"), { recursive: true });
146
+ await Bun.write(join(overstoryDir, "agent-manifest.json"), JSON.stringify(manifest, null, 2));
147
+ await Bun.write(join(overstoryDir, "agent-defs", "scout.md"), "# Scout");
148
+
149
+ const checks = await checkAgents(mockConfig, overstoryDir);
150
+
151
+ const parseCheck = checks.find((c) => c.name === "Manifest parsing");
152
+ expect(parseCheck?.status).toBe("pass");
153
+ });
154
+
155
+ test("fails when agent model is empty", async () => {
156
+ const manifest = {
157
+ version: "1.0",
158
+ agents: {
159
+ scout: {
160
+ file: "scout.md",
161
+ model: "",
134
162
  tools: ["Read"],
135
163
  capabilities: ["explore"],
136
164
  canSpawn: false,
@@ -148,7 +176,9 @@ describe("checkAgents", () => {
148
176
 
149
177
  const parseCheck = checks.find((c) => c.name === "Manifest parsing");
150
178
  expect(parseCheck?.status).toBe("fail");
151
- expect(parseCheck?.details?.some((d) => d.includes("model"))).toBe(true);
179
+ expect(parseCheck?.details?.some((d) => d.includes('"model" must be a non-empty string'))).toBe(
180
+ true,
181
+ );
152
182
  });
153
183
 
154
184
  test("fails when agent has zero capabilities", async () => {
@@ -378,7 +408,44 @@ sessionsCompleted: -5
378
408
  expect(identityCheck?.details?.some((d) => d.includes("sessionsCompleted"))).toBe(true);
379
409
  });
380
410
 
381
- test("warns about stale identity files", async () => {
411
+ test("does not warn for runtime-named identities when the recorded role exists", async () => {
412
+ const manifest = {
413
+ version: "1.0",
414
+ agents: {
415
+ scout: {
416
+ file: "scout.md",
417
+ model: "haiku",
418
+ tools: ["Read"],
419
+ capabilities: ["explore"],
420
+ canSpawn: false,
421
+ constraints: [],
422
+ },
423
+ },
424
+ capabilityIndex: {
425
+ explore: ["scout"],
426
+ },
427
+ };
428
+
429
+ await mkdir(join(overstoryDir, "agent-defs"), { recursive: true });
430
+ await mkdir(join(overstoryDir, "agents", "scout-task-123"), { recursive: true });
431
+ await Bun.write(join(overstoryDir, "agent-manifest.json"), JSON.stringify(manifest, null, 2));
432
+ await Bun.write(join(overstoryDir, "agent-defs", "scout.md"), "# Scout");
433
+
434
+ const identity = `name: scout-task-123
435
+ capability: scout
436
+ created: "2024-01-01T00:00:00Z"
437
+ sessionsCompleted: 5
438
+ `;
439
+
440
+ await Bun.write(join(overstoryDir, "agents", "scout-task-123", "identity.yaml"), identity);
441
+
442
+ const checks = await checkAgents(mockConfig, overstoryDir);
443
+
444
+ const staleCheck = checks.find((c) => c.name === "Stale identities");
445
+ expect(staleCheck).toBeUndefined();
446
+ });
447
+
448
+ test("warns about stale identity files when the recorded role is missing", async () => {
382
449
  const manifest = {
383
450
  version: "1.0",
384
451
  agents: {
@@ -413,7 +480,7 @@ sessionsCompleted: 5
413
480
 
414
481
  const staleCheck = checks.find((c) => c.name === "Stale identities");
415
482
  expect(staleCheck?.status).toBe("warn");
416
- expect(staleCheck?.details?.some((d) => d.includes("old-agent"))).toBe(true);
483
+ expect(staleCheck?.details?.some((d) => d.includes("obsolete"))).toBe(true);
417
484
  });
418
485
 
419
486
  test("warns when identity name contains invalid characters", async () => {
@@ -3,7 +3,6 @@ import { join } from "node:path";
3
3
  import type { AgentManifest } from "../types.ts";
4
4
  import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
5
5
 
6
- const VALID_MODELS = new Set(["sonnet", "opus", "haiku"]);
7
6
  const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
8
7
 
9
8
  /**
@@ -64,8 +63,8 @@ async function loadAndValidateManifest(
64
63
  errors.push(`Agent "${name}": "file" must be a non-empty string`);
65
64
  }
66
65
 
67
- if (typeof agentDef.model !== "string" || !VALID_MODELS.has(agentDef.model)) {
68
- errors.push(`Agent "${name}": "model" must be one of: sonnet, opus, haiku`);
66
+ if (typeof agentDef.model !== "string" || agentDef.model.length === 0) {
67
+ errors.push(`Agent "${name}": "model" must be a non-empty string`);
69
68
  }
70
69
 
71
70
  if (!Array.isArray(agentDef.tools)) {
@@ -313,12 +312,6 @@ export const checkAgents: DoctorCheckFn = async (_config, overstoryDir): Promise
313
312
 
314
313
  identityFileCount++;
315
314
 
316
- // Check if agent still exists in manifest
317
- if (!manifest.agents[agentName]) {
318
- staleIdentities.push(agentName);
319
- continue;
320
- }
321
-
322
315
  // Parse and validate identity
323
316
  try {
324
317
  const content = await Bun.file(identityPath).text();
@@ -346,6 +339,13 @@ export const checkAgents: DoctorCheckFn = async (_config, overstoryDir): Promise
346
339
  identityErrors.push(`${agentName}: "sessionsCompleted" must be a non-negative integer`);
347
340
  }
348
341
 
342
+ // Identity directories are keyed by runtime agent names (for example
343
+ // lead-foo-1234), not by manifest role names. Validate the recorded
344
+ // role/capability against the manifest instead of the directory name.
345
+ if (identity.capability && !manifest.agents[identity.capability]) {
346
+ staleIdentities.push(`${agentName} (capability: ${identity.capability})`);
347
+ }
348
+
349
349
  // Validate name is valid identifier
350
350
  if (identity.name && !VALID_NAME_REGEX.test(identity.name)) {
351
351
  identityErrors.push(
@@ -384,7 +384,7 @@ export const checkAgents: DoctorCheckFn = async (_config, overstoryDir): Promise
384
384
  category: "agents",
385
385
  status: "warn",
386
386
  message: `Found ${staleIdentities.length} stale identity file(s)`,
387
- details: staleIdentities.map((name) => `${name} (agent no longer in manifest)`),
387
+ details: staleIdentities.map((name) => `${name} (role not present in manifest)`),
388
388
  fixable: true,
389
389
  });
390
390
  }
@@ -375,6 +375,41 @@ describe("checkConsistency", () => {
375
375
  expect(warnOrFail.length).toBe(0);
376
376
  });
377
377
 
378
+ test("ignores completed sessions whose tmux, pid, and worktree are gone", async () => {
379
+ const dbPath = join(overstoryDir, "sessions.db");
380
+ const store = createSessionStore(dbPath);
381
+
382
+ store.upsert({
383
+ id: "session-1",
384
+ agentName: "completed-agent",
385
+ capability: "builder",
386
+ worktreePath: join(overstoryDir, "worktrees", "completed-agent"),
387
+ branchName: "overstory/completed-agent/test-123",
388
+ taskId: "test-123",
389
+ tmuxSession: "overstory-testproject-completed-agent",
390
+ state: "completed",
391
+ pid: 99999,
392
+ parentAgent: null,
393
+ depth: 0,
394
+ runId: null,
395
+ startedAt: new Date().toISOString(),
396
+ lastActivity: new Date().toISOString(),
397
+ escalationLevel: 0,
398
+ stalledSince: null,
399
+ transcriptPath: null,
400
+ });
401
+ store.close();
402
+
403
+ mockIsProcessAlive.mockReturnValue(false);
404
+ mockListSessions.mockResolvedValue([]);
405
+
406
+ const checks = await checkConsistency(config, overstoryDir, mockDeps);
407
+
408
+ expect(checks.find((c) => c.name === "dead-pids")?.status).toBe("pass");
409
+ expect(checks.find((c) => c.name === "missing-worktrees")?.status).toBe("pass");
410
+ expect(checks.find((c) => c.name === "missing-tmux")?.status).toBe("pass");
411
+ });
412
+
378
413
  test("handles tmux not installed gracefully", async () => {
379
414
  // Mock tmux listing to throw an error
380
415
  mockListSessions.mockRejectedValue(new Error("tmux: command not found"));
@@ -89,6 +89,10 @@ export async function checkConsistency(
89
89
  return checks;
90
90
  }
91
91
 
92
+ // Completed/zombie sessions are retained for history and metrics. Their tmux
93
+ // sessions, PIDs, and worktrees may legitimately be gone.
94
+ const liveSessions = storeSessions.filter((s) => s.state !== "completed" && s.state !== "zombie");
95
+
92
96
  // Now perform cross-validation checks
93
97
 
94
98
  // 4. Check for orphaned worktrees (worktree exists but no SessionStore entry)
@@ -155,7 +159,7 @@ export async function checkConsistency(
155
159
  }
156
160
 
157
161
  // 6. Check for dead processes in SessionStore
158
- const deadSessions = storeSessions.filter((s) => s.pid !== null && !isProcessAliveFn(s.pid));
162
+ const deadSessions = liveSessions.filter((s) => s.pid !== null && !isProcessAliveFn(s.pid));
159
163
 
160
164
  if (deadSessions.length > 0) {
161
165
  checks.push({
@@ -177,7 +181,7 @@ export async function checkConsistency(
177
181
 
178
182
  // 7. Check for SessionStore entries with missing worktrees
179
183
  const existingWorktreePaths = new Set(worktrees.map((wt) => wt.path));
180
- const missingWorktrees = storeSessions.filter((s) => {
184
+ const missingWorktrees = liveSessions.filter((s) => {
181
185
  // Try to normalize the SessionStore path for comparison
182
186
  try {
183
187
  const normalizedPath = realpathSync(s.worktreePath);
@@ -208,7 +212,7 @@ export async function checkConsistency(
208
212
 
209
213
  // 8. Check for SessionStore entries with missing tmux sessions
210
214
  const existingTmuxNames = new Set(tmuxSessions.map((s) => s.name));
211
- const missingTmux = storeSessions.filter((s) => !existingTmuxNames.has(s.tmuxSession));
215
+ const missingTmux = liveSessions.filter((s) => !existingTmuxNames.has(s.tmuxSession));
212
216
 
213
217
  if (missingTmux.length > 0) {
214
218
  checks.push({