@os-eco/overstory-cli 0.9.1 → 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 (87) hide show
  1. package/README.md +20 -6
  2. package/agents/coordinator.md +30 -6
  3. package/agents/lead.md +11 -1
  4. package/package.json +1 -1
  5. package/src/agents/hooks-deployer.test.ts +9 -1
  6. package/src/agents/hooks-deployer.ts +2 -1
  7. package/src/agents/overlay.test.ts +26 -0
  8. package/src/agents/overlay.ts +18 -4
  9. package/src/commands/agents.ts +1 -1
  10. package/src/commands/clean.test.ts +3 -0
  11. package/src/commands/clean.ts +1 -58
  12. package/src/commands/completions.test.ts +18 -6
  13. package/src/commands/completions.ts +40 -1
  14. package/src/commands/coordinator.test.ts +77 -4
  15. package/src/commands/coordinator.ts +226 -124
  16. package/src/commands/dashboard.ts +46 -9
  17. package/src/commands/doctor.ts +3 -1
  18. package/src/commands/ecosystem.test.ts +126 -1
  19. package/src/commands/ecosystem.ts +7 -53
  20. package/src/commands/feed.test.ts +117 -2
  21. package/src/commands/feed.ts +46 -30
  22. package/src/commands/group.test.ts +274 -155
  23. package/src/commands/group.ts +11 -5
  24. package/src/commands/init.ts +8 -0
  25. package/src/commands/log.test.ts +35 -0
  26. package/src/commands/log.ts +10 -6
  27. package/src/commands/logs.test.ts +423 -1
  28. package/src/commands/logs.ts +99 -104
  29. package/src/commands/orchestrator.ts +42 -0
  30. package/src/commands/prime.test.ts +177 -2
  31. package/src/commands/prime.ts +4 -2
  32. package/src/commands/sling.ts +3 -3
  33. package/src/commands/upgrade.test.ts +2 -0
  34. package/src/commands/upgrade.ts +1 -17
  35. package/src/commands/watch.test.ts +67 -1
  36. package/src/commands/watch.ts +4 -79
  37. package/src/config.test.ts +250 -0
  38. package/src/config.ts +43 -0
  39. package/src/doctor/agents.test.ts +72 -5
  40. package/src/doctor/agents.ts +10 -10
  41. package/src/doctor/consistency.test.ts +35 -0
  42. package/src/doctor/consistency.ts +7 -3
  43. package/src/doctor/dependencies.test.ts +58 -1
  44. package/src/doctor/dependencies.ts +4 -2
  45. package/src/doctor/providers.test.ts +41 -5
  46. package/src/doctor/types.ts +2 -1
  47. package/src/doctor/version.test.ts +106 -2
  48. package/src/doctor/version.ts +4 -2
  49. package/src/doctor/watchdog.test.ts +167 -0
  50. package/src/doctor/watchdog.ts +158 -0
  51. package/src/e2e/init-sling-lifecycle.test.ts +2 -1
  52. package/src/errors.test.ts +350 -0
  53. package/src/events/tailer.test.ts +25 -0
  54. package/src/events/tailer.ts +8 -1
  55. package/src/index.ts +4 -1
  56. package/src/mail/store.test.ts +110 -0
  57. package/src/runtimes/aider.test.ts +124 -0
  58. package/src/runtimes/aider.ts +147 -0
  59. package/src/runtimes/amp.test.ts +164 -0
  60. package/src/runtimes/amp.ts +154 -0
  61. package/src/runtimes/claude.test.ts +4 -2
  62. package/src/runtimes/goose.test.ts +133 -0
  63. package/src/runtimes/goose.ts +157 -0
  64. package/src/runtimes/pi-guards.ts +2 -1
  65. package/src/runtimes/pi.test.ts +9 -9
  66. package/src/runtimes/pi.ts +6 -7
  67. package/src/runtimes/registry.test.ts +1 -1
  68. package/src/runtimes/registry.ts +13 -4
  69. package/src/runtimes/sapling.ts +2 -1
  70. package/src/runtimes/types.ts +2 -2
  71. package/src/types.ts +4 -0
  72. package/src/utils/bin.test.ts +10 -0
  73. package/src/utils/bin.ts +37 -0
  74. package/src/utils/fs.test.ts +119 -0
  75. package/src/utils/fs.ts +62 -0
  76. package/src/utils/pid.test.ts +68 -0
  77. package/src/utils/pid.ts +45 -0
  78. package/src/utils/time.test.ts +43 -0
  79. package/src/utils/time.ts +37 -0
  80. package/src/utils/version.test.ts +33 -0
  81. package/src/utils/version.ts +70 -0
  82. package/src/watchdog/daemon.test.ts +255 -1
  83. package/src/watchdog/daemon.ts +46 -9
  84. package/src/watchdog/health.test.ts +15 -1
  85. package/src/watchdog/health.ts +1 -1
  86. package/src/watchdog/triage.test.ts +49 -9
  87. package/src/watchdog/triage.ts +21 -5
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { OverstoryConfig } from "../types.ts";
3
- import { checkDependencies } from "./dependencies.ts";
3
+ import { checkAlias, checkDependencies, checkTool } from "./dependencies.ts";
4
4
 
5
5
  // Minimal config for testing
6
6
  const mockConfig: OverstoryConfig = {
@@ -237,3 +237,60 @@ describe("checkDependencies", () => {
237
237
  expect(ovCheck?.category).toBe("dependencies");
238
238
  });
239
239
  });
240
+
241
+ describe("checkTool", () => {
242
+ test("git with --version passes", async () => {
243
+ const check = await checkTool("git", "--version", true);
244
+ expect(check.name).toBe("git availability");
245
+ expect(check.category).toBe("dependencies");
246
+ expect(check.status).toBe("pass");
247
+ expect(check.message).toContain("git");
248
+ expect(check.details).toBeArray();
249
+ expect(check.details?.length).toBeGreaterThan(0);
250
+ });
251
+
252
+ test("nonexistent tool with required: true returns fail", async () => {
253
+ const check = await checkTool("nonexistent-tool-xyz-999", "--version", true);
254
+ expect(check.status).toBe("fail");
255
+ expect(check.message).toContain("nonexistent-tool-xyz-999");
256
+ expect(check.fixable).toBe(true);
257
+ });
258
+
259
+ test("nonexistent tool with required: false returns warn", async () => {
260
+ const check = await checkTool("nonexistent-tool-xyz-999", "--version", false);
261
+ expect(check.status).toBe("warn");
262
+ expect(check.fixable).toBe(true);
263
+ });
264
+
265
+ test("installHint appears in details for missing tool", async () => {
266
+ const check = await checkTool("nonexistent-tool-xyz-999", "--version", true, "@test/fake-pkg");
267
+ expect(check.status).toBe("fail");
268
+ const detailsText = check.details?.join(" ") ?? "";
269
+ expect(detailsText).toContain("npm install -g @test/fake-pkg");
270
+ });
271
+ });
272
+
273
+ describe("checkAlias", () => {
274
+ test("real tool alias passes", async () => {
275
+ // git is universally available — use it as a "real alias"
276
+ const check = await checkAlias("git-tool", "git");
277
+ expect(check.name).toBe("git alias");
278
+ expect(check.category).toBe("dependencies");
279
+ expect(check.status).toBe("pass");
280
+ expect(check.message).toContain("git");
281
+ });
282
+
283
+ test("nonexistent alias returns warn", async () => {
284
+ const check = await checkAlias("some-tool", "nonexistent-alias-xyz-999");
285
+ expect(check.status).toBe("warn");
286
+ expect(check.name).toBe("nonexistent-alias-xyz-999 alias");
287
+ expect(check.fixable).toBe(true);
288
+ });
289
+
290
+ test("nonexistent alias with installHint includes hint in details", async () => {
291
+ const check = await checkAlias("some-tool", "nonexistent-alias-xyz-999", "@test/fake-pkg");
292
+ expect(check.status).toBe("warn");
293
+ const detailsText = check.details?.join(" ") ?? "";
294
+ expect(detailsText).toContain("@test/fake-pkg");
295
+ });
296
+ });
@@ -157,8 +157,9 @@ async function checkBdCgoSupport(): Promise<DoctorCheck> {
157
157
 
158
158
  /**
159
159
  * Check if a short alias for a CLI tool is available.
160
+ * @internal Exported for testing.
160
161
  */
161
- async function checkAlias(
162
+ export async function checkAlias(
162
163
  toolName: string,
163
164
  alias: string,
164
165
  installHint?: string,
@@ -208,8 +209,9 @@ async function checkAlias(
208
209
 
209
210
  /**
210
211
  * Check if a CLI tool is available by attempting to run it with a version flag.
212
+ * @internal Exported for testing.
211
213
  */
212
- async function checkTool(
214
+ export async function checkTool(
213
215
  name: string,
214
216
  versionFlag: string,
215
217
  required: boolean,
@@ -104,7 +104,7 @@ describe("checkProviders", () => {
104
104
  const config = makeConfig({
105
105
  providers: {
106
106
  anthropic: { type: "native" },
107
- openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
107
+ openrouter: { type: "gateway", baseUrl: "http://127.0.0.1:19873" },
108
108
  },
109
109
  });
110
110
  const checks = await checkProviders(config, OVERSTORY_DIR);
@@ -241,11 +241,29 @@ describe("checkProviders", () => {
241
241
  });
242
242
 
243
243
  describe("tool-use-compat check", () => {
244
+ // Local HTTP server to avoid network calls to real openrouter.ai
245
+ let localServer: ReturnType<typeof Bun.serve>;
246
+ let localBaseUrl: string;
247
+
248
+ beforeAll(() => {
249
+ localServer = Bun.serve({
250
+ port: 0,
251
+ fetch() {
252
+ return new Response("ok");
253
+ },
254
+ });
255
+ localBaseUrl = `http://127.0.0.1:${localServer.port}`;
256
+ });
257
+
258
+ afterAll(async () => {
259
+ await localServer.stop();
260
+ });
261
+
244
262
  test("tool-heavy role with provider-prefixed model warns", async () => {
245
263
  const config = makeConfig({
246
264
  models: { builder: "openrouter/openai/gpt-4o" },
247
265
  providers: {
248
- openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
266
+ openrouter: { type: "gateway", baseUrl: localBaseUrl },
249
267
  },
250
268
  });
251
269
  const checks = await checkProviders(config, OVERSTORY_DIR);
@@ -260,7 +278,7 @@ describe("checkProviders", () => {
260
278
  const config = makeConfig({
261
279
  models: { lead: "openrouter/openai/gpt-4o" },
262
280
  providers: {
263
- openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
281
+ openrouter: { type: "gateway", baseUrl: localBaseUrl },
264
282
  },
265
283
  });
266
284
  const checks = await checkProviders(config, OVERSTORY_DIR);
@@ -287,7 +305,7 @@ describe("checkProviders", () => {
287
305
  merger: "openrouter/openai/gpt-4o",
288
306
  },
289
307
  providers: {
290
- openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
308
+ openrouter: { type: "gateway", baseUrl: localBaseUrl },
291
309
  },
292
310
  });
293
311
  const checks = await checkProviders(config, OVERSTORY_DIR);
@@ -298,6 +316,24 @@ describe("checkProviders", () => {
298
316
  });
299
317
 
300
318
  describe("model-provider-ref(s) check", () => {
319
+ // Local HTTP server to avoid network calls to real openrouter.ai
320
+ let localServer: ReturnType<typeof Bun.serve>;
321
+ let localBaseUrl: string;
322
+
323
+ beforeAll(() => {
324
+ localServer = Bun.serve({
325
+ port: 0,
326
+ fetch() {
327
+ return new Response("ok");
328
+ },
329
+ });
330
+ localBaseUrl = `http://127.0.0.1:${localServer.port}`;
331
+ });
332
+
333
+ afterAll(async () => {
334
+ await localServer.stop();
335
+ });
336
+
301
337
  test("model referencing unknown provider fails", async () => {
302
338
  const config = makeConfig({
303
339
  models: { builder: "unknownprovider/some-model" },
@@ -315,7 +351,7 @@ describe("checkProviders", () => {
315
351
  const config = makeConfig({
316
352
  models: { builder: "openrouter/openai/gpt-4o" },
317
353
  providers: {
318
- openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
354
+ openrouter: { type: "gateway", baseUrl: localBaseUrl },
319
355
  },
320
356
  });
321
357
  const checks = await checkProviders(config, OVERSTORY_DIR);
@@ -14,7 +14,8 @@ export type DoctorCategory =
14
14
  | "logs"
15
15
  | "version"
16
16
  | "ecosystem"
17
- | "providers";
17
+ | "providers"
18
+ | "watchdog";
18
19
 
19
20
  /** Result of a single doctor health check. */
20
21
  export interface DoctorCheck {
@@ -1,6 +1,10 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cleanupTempDir } from "../test-helpers.ts";
2
6
  import type { OverstoryConfig } from "../types.ts";
3
- import { checkVersion } from "./version.ts";
7
+ import { checkCurrentVersion, checkVersion, checkVersionSync } from "./version.ts";
4
8
 
5
9
  // Minimal config for testing
6
10
  const mockConfig: OverstoryConfig = {
@@ -135,3 +139,103 @@ describe("checkVersion", () => {
135
139
  }
136
140
  });
137
141
  });
142
+
143
+ describe("checkCurrentVersion", () => {
144
+ test("passes against real repo root", async () => {
145
+ // Use the real overstory repo root (two levels up from src/doctor/)
146
+ const toolRoot = join(import.meta.dir, "..", "..");
147
+ const check = await checkCurrentVersion(toolRoot);
148
+ expect(check.name).toBe("version-current");
149
+ expect(check.category).toBe("version");
150
+ expect(check.status).toBe("pass");
151
+ expect(check.message).toMatch(/ov v\d+\.\d+\.\d+/);
152
+ });
153
+
154
+ test("fails for temp dir without version field", async () => {
155
+ const tempDir = await mkdtemp(join(tmpdir(), "version-test-"));
156
+ try {
157
+ // Write a package.json without a version field
158
+ await Bun.write(join(tempDir, "package.json"), JSON.stringify({ name: "test" }));
159
+ const check = await checkCurrentVersion(tempDir);
160
+ expect(check.name).toBe("version-current");
161
+ expect(check.status).toBe("fail");
162
+ expect(check.message).toContain("no version field");
163
+ } finally {
164
+ await cleanupTempDir(tempDir);
165
+ }
166
+ });
167
+
168
+ test("fails for temp dir without package.json", async () => {
169
+ const tempDir = await mkdtemp(join(tmpdir(), "version-test-"));
170
+ try {
171
+ const check = await checkCurrentVersion(tempDir);
172
+ expect(check.name).toBe("version-current");
173
+ expect(check.status).toBe("fail");
174
+ } finally {
175
+ await cleanupTempDir(tempDir);
176
+ }
177
+ });
178
+ });
179
+
180
+ describe("checkVersionSync", () => {
181
+ let tempDir: string;
182
+
183
+ beforeEach(async () => {
184
+ tempDir = await mkdtemp(join(tmpdir(), "version-sync-test-"));
185
+ });
186
+
187
+ afterEach(async () => {
188
+ await cleanupTempDir(tempDir);
189
+ });
190
+
191
+ test("passes when versions match", async () => {
192
+ await Bun.write(
193
+ join(tempDir, "package.json"),
194
+ JSON.stringify({ name: "test", version: "1.2.3" }),
195
+ );
196
+ const { mkdir } = await import("node:fs/promises");
197
+ await mkdir(join(tempDir, "src"), { recursive: true });
198
+ await Bun.write(
199
+ join(tempDir, "src", "index.ts"),
200
+ 'const VERSION = "1.2.3";\nexport { VERSION };\n',
201
+ );
202
+
203
+ const check = await checkVersionSync(tempDir);
204
+ expect(check.name).toBe("package-json-sync");
205
+ expect(check.category).toBe("version");
206
+ expect(check.status).toBe("pass");
207
+ expect(check.message).toContain("synchronized");
208
+ expect(check.details?.join(" ")).toContain("1.2.3");
209
+ });
210
+
211
+ test("warns when versions mismatch", async () => {
212
+ await Bun.write(
213
+ join(tempDir, "package.json"),
214
+ JSON.stringify({ name: "test", version: "1.0.0" }),
215
+ );
216
+ const { mkdir } = await import("node:fs/promises");
217
+ await mkdir(join(tempDir, "src"), { recursive: true });
218
+ await Bun.write(
219
+ join(tempDir, "src", "index.ts"),
220
+ 'const VERSION = "2.0.0";\nexport { VERSION };\n',
221
+ );
222
+
223
+ const check = await checkVersionSync(tempDir);
224
+ expect(check.name).toBe("package-json-sync");
225
+ expect(check.status).toBe("warn");
226
+ expect(check.message).toContain("mismatch");
227
+ expect(check.fixable).toBe(true);
228
+ });
229
+
230
+ test("warns when src/index.ts is missing", async () => {
231
+ await Bun.write(
232
+ join(tempDir, "package.json"),
233
+ JSON.stringify({ name: "test", version: "1.0.0" }),
234
+ );
235
+ // No src/index.ts created
236
+
237
+ const check = await checkVersionSync(tempDir);
238
+ expect(check.name).toBe("package-json-sync");
239
+ expect(check.status).toBe("warn");
240
+ });
241
+ });
@@ -28,8 +28,9 @@ export const checkVersion: DoctorCheckFn = async (
28
28
 
29
29
  /**
30
30
  * Check that the current version can be determined from package.json.
31
+ * @internal Exported for testing.
31
32
  */
32
- async function checkCurrentVersion(toolRoot: string): Promise<DoctorCheck> {
33
+ export async function checkCurrentVersion(toolRoot: string): Promise<DoctorCheck> {
33
34
  try {
34
35
  const packageJsonPath = join(toolRoot, "package.json");
35
36
  const packageJson = (await Bun.file(packageJsonPath).json()) as { version?: string };
@@ -62,8 +63,9 @@ async function checkCurrentVersion(toolRoot: string): Promise<DoctorCheck> {
62
63
 
63
64
  /**
64
65
  * Check that package.json version matches src/index.ts VERSION constant.
66
+ * @internal Exported for testing.
65
67
  */
66
- async function checkVersionSync(toolRoot: string): Promise<DoctorCheck> {
68
+ export async function checkVersionSync(toolRoot: string): Promise<DoctorCheck> {
67
69
  try {
68
70
  // Read package.json version
69
71
  const packageJsonPath = join(toolRoot, "package.json");
@@ -0,0 +1,167 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { utimes } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { OverstoryConfig } from "../types.ts";
7
+ import { checkWatchdog } from "./watchdog.ts";
8
+
9
+ describe("checkWatchdog", () => {
10
+ let tempDir: string;
11
+ let mockConfig: OverstoryConfig;
12
+
13
+ beforeEach(() => {
14
+ tempDir = mkdtempSync(join(tmpdir(), "overstory-watchdog-test-"));
15
+ mockConfig = {
16
+ project: { name: "test", root: tempDir, canonicalBranch: "main" },
17
+ agents: {
18
+ manifestPath: "",
19
+ baseDir: "",
20
+ maxConcurrent: 5,
21
+ staggerDelayMs: 100,
22
+ maxDepth: 2,
23
+ maxSessionsPerRun: 0,
24
+ maxAgentsPerLead: 5,
25
+ },
26
+ worktrees: { baseDir: "" },
27
+ taskTracker: { backend: "auto", enabled: true },
28
+ mulch: { enabled: true, domains: [], primeFormat: "markdown" },
29
+ merge: { aiResolveEnabled: false, reimagineEnabled: false },
30
+ providers: {
31
+ anthropic: { type: "native" },
32
+ },
33
+ watchdog: {
34
+ tier0Enabled: true,
35
+ tier0IntervalMs: 30000,
36
+ tier1Enabled: false,
37
+ tier2Enabled: false,
38
+ staleThresholdMs: 300000,
39
+ zombieThresholdMs: 600000,
40
+ nudgeIntervalMs: 60000,
41
+ },
42
+ models: {},
43
+ logging: { verbose: false, redactSecrets: true },
44
+ };
45
+ });
46
+
47
+ afterEach(() => {
48
+ rmSync(tempDir, { recursive: true, force: true });
49
+ });
50
+
51
+ test("all checks skip when tier0Enabled is false — returns single pass check", async () => {
52
+ mockConfig.watchdog.tier0Enabled = false;
53
+ const checks = await checkWatchdog(mockConfig, tempDir);
54
+
55
+ expect(checks).toHaveLength(1);
56
+ expect(checks[0]?.status).toBe("pass");
57
+ expect(checks[0]?.message).toContain("disabled");
58
+ });
59
+
60
+ test("PID file missing — returns warn about daemon not running", async () => {
61
+ const checks = await checkWatchdog(mockConfig, tempDir);
62
+
63
+ const pidCheck = checks.find((c) => c.name === "watchdog pid file");
64
+ expect(pidCheck).toBeDefined();
65
+ expect(pidCheck?.status).toBe("warn");
66
+ expect(pidCheck?.message).toContain("PID file not found");
67
+ });
68
+
69
+ test("PID file corrupted — returns fail with fixable", async () => {
70
+ writeFileSync(join(tempDir, "watchdog.pid"), "not-a-pid");
71
+ const checks = await checkWatchdog(mockConfig, tempDir);
72
+
73
+ const integrityCheck = checks.find((c) => c.name === "watchdog pid integrity");
74
+ expect(integrityCheck).toBeDefined();
75
+ expect(integrityCheck?.status).toBe("fail");
76
+ expect(integrityCheck?.fixable).toBe(true);
77
+ expect(integrityCheck?.details?.some((d) => d.includes("not-a-pid"))).toBe(true);
78
+ });
79
+
80
+ test("PID file with valid PID but process not running — returns warn (stale PID)", async () => {
81
+ // PID 999999999 is extremely unlikely to exist
82
+ writeFileSync(join(tempDir, "watchdog.pid"), "999999999");
83
+ const checks = await checkWatchdog(mockConfig, tempDir);
84
+
85
+ const processCheck = checks.find((c) => c.name === "watchdog process");
86
+ expect(processCheck).toBeDefined();
87
+ expect(processCheck?.status).toBe("warn");
88
+ expect(processCheck?.message).toContain("stale PID file");
89
+ expect(processCheck?.fixable).toBe(true);
90
+ });
91
+
92
+ test("PID file with current process PID — returns pass", async () => {
93
+ writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
94
+ const checks = await checkWatchdog(mockConfig, tempDir);
95
+
96
+ const processCheck = checks.find((c) => c.name === "watchdog process");
97
+ expect(processCheck).toBeDefined();
98
+ expect(processCheck?.status).toBe("pass");
99
+ expect(processCheck?.message).toContain("running");
100
+ });
101
+
102
+ test("PID file older than 24 hours — returns staleness warn", async () => {
103
+ const pidFile = join(tempDir, "watchdog.pid");
104
+ writeFileSync(pidFile, String(process.pid));
105
+
106
+ // Set mtime 25 hours ago
107
+ const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000);
108
+ await utimes(pidFile, twentyFiveHoursAgo, twentyFiveHoursAgo);
109
+
110
+ const checks = await checkWatchdog(mockConfig, tempDir);
111
+
112
+ const stalenessCheck = checks.find((c) => c.name === "watchdog pid staleness");
113
+ expect(stalenessCheck).toBeDefined();
114
+ expect(stalenessCheck?.status).toBe("warn");
115
+ expect(stalenessCheck?.message).toContain("older than 24 hours");
116
+ expect(stalenessCheck?.details?.some((d) => d.includes("hours"))).toBe(true);
117
+ });
118
+
119
+ test("Tier 2 monitor check skipped when tier2Enabled=false — no monitor check in results", async () => {
120
+ mockConfig.watchdog.tier2Enabled = false;
121
+ writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
122
+ const checks = await checkWatchdog(mockConfig, tempDir);
123
+
124
+ const monitorCheck = checks.find((c) => c.name === "tier2 monitor");
125
+ expect(monitorCheck).toBeUndefined();
126
+ });
127
+
128
+ test("Tier 1 triage check skipped when tier1Enabled=false — no triage check in results", async () => {
129
+ mockConfig.watchdog.tier1Enabled = false;
130
+ writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
131
+ const checks = await checkWatchdog(mockConfig, tempDir);
132
+
133
+ const triageCheck = checks.find((c) => c.name === "tier1 triage");
134
+ expect(triageCheck).toBeUndefined();
135
+ });
136
+
137
+ test("Tier 2 monitor check warns when no monitor session found", async () => {
138
+ mockConfig.watchdog.tier2Enabled = true;
139
+ writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
140
+ // No sessions.db or sessions.json — openSessionStore creates empty DB
141
+ const checks = await checkWatchdog(mockConfig, tempDir);
142
+
143
+ const monitorCheck = checks.find((c) => c.name === "tier2 monitor");
144
+ expect(monitorCheck).toBeDefined();
145
+ // Either warns about not running or store unavailable — both are acceptable
146
+ expect(monitorCheck?.status).toBe("warn");
147
+ });
148
+
149
+ test("Tier 1 triage check does not crash when enabled", async () => {
150
+ mockConfig.watchdog.tier1Enabled = true;
151
+ writeFileSync(join(tempDir, "watchdog.pid"), String(process.pid));
152
+ // getRuntime will succeed (defaults to "claude" which is always registered)
153
+ let checks: Awaited<ReturnType<typeof checkWatchdog>>;
154
+ try {
155
+ checks = await checkWatchdog(mockConfig, tempDir);
156
+ } catch {
157
+ // Should not throw
158
+ expect(false).toBe(true);
159
+ return;
160
+ }
161
+
162
+ const triageCheck = checks.find((c) => c.name === "tier1 triage");
163
+ expect(triageCheck).toBeDefined();
164
+ // Either pass or warn — depending on environment; it should not throw
165
+ expect(triageCheck?.status === "pass" || triageCheck?.status === "warn").toBe(true);
166
+ });
167
+ });
@@ -0,0 +1,158 @@
1
+ import { existsSync } from "node:fs";
2
+ import { stat, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getRuntime } from "../runtimes/registry.ts";
5
+ import { openSessionStore } from "../sessions/compat.ts";
6
+ import { isProcessRunning } from "../watchdog/health.ts";
7
+ import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
8
+
9
+ /**
10
+ * Watchdog subsystem health checks.
11
+ * Validates PID file integrity, process liveness, and tier availability.
12
+ */
13
+ export const checkWatchdog: DoctorCheckFn = async (
14
+ config,
15
+ overstoryDir,
16
+ ): Promise<DoctorCheck[]> => {
17
+ const checks: DoctorCheck[] = [];
18
+
19
+ // If tier0 is disabled, skip all checks with a single pass result
20
+ if (!config.watchdog.tier0Enabled) {
21
+ checks.push({
22
+ name: "watchdog disabled",
23
+ category: "watchdog",
24
+ status: "pass",
25
+ message: "Watchdog daemon is disabled (tier0Enabled: false)",
26
+ });
27
+ return checks;
28
+ }
29
+
30
+ const pidFilePath = join(overstoryDir, "watchdog.pid");
31
+
32
+ // Check 1: PID file exists and is readable
33
+ if (!existsSync(pidFilePath)) {
34
+ checks.push({
35
+ name: "watchdog pid file",
36
+ category: "watchdog",
37
+ status: "warn",
38
+ message: "Watchdog PID file not found — daemon may not be running",
39
+ });
40
+ } else {
41
+ // Check 2: PID file not corrupted
42
+ const pidText = await Bun.file(pidFilePath).text();
43
+ const pid = Number.parseInt(pidText.trim(), 10);
44
+
45
+ if (Number.isNaN(pid) || pid <= 0) {
46
+ checks.push({
47
+ name: "watchdog pid integrity",
48
+ category: "watchdog",
49
+ status: "fail",
50
+ message: "Watchdog PID file is corrupted",
51
+ details: [`Raw content: ${pidText.trim()}`],
52
+ fixable: true,
53
+ fix: async () => {
54
+ await unlink(pidFilePath);
55
+ return ["Removed corrupted watchdog PID file"];
56
+ },
57
+ });
58
+ } else {
59
+ // Check 3: PID alive via isProcessRunning()
60
+ const alive = isProcessRunning(pid);
61
+ if (!alive) {
62
+ checks.push({
63
+ name: "watchdog process",
64
+ category: "watchdog",
65
+ status: "warn",
66
+ message: "Watchdog process is not running (stale PID file)",
67
+ details: [`PID: ${pid}`],
68
+ fixable: true,
69
+ fix: async () => {
70
+ await unlink(pidFilePath);
71
+ return ["Removed stale watchdog PID file"];
72
+ },
73
+ });
74
+ } else {
75
+ checks.push({
76
+ name: "watchdog process",
77
+ category: "watchdog",
78
+ status: "pass",
79
+ message: "Watchdog daemon is running",
80
+ });
81
+ }
82
+
83
+ // Check 4: PID file staleness > 24h
84
+ const fileStat = await stat(pidFilePath);
85
+ const ageMs = Date.now() - fileStat.mtimeMs;
86
+ const twentyFourHoursMs = 24 * 60 * 60 * 1000;
87
+ if (ageMs > twentyFourHoursMs) {
88
+ const ageHours = Math.round(ageMs / (1000 * 60 * 60));
89
+ checks.push({
90
+ name: "watchdog pid staleness",
91
+ category: "watchdog",
92
+ status: "warn",
93
+ message: "Watchdog PID file is older than 24 hours",
94
+ details: [`File age: ${ageHours} hours`],
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ // Check 5: Tier 2 monitor running if tier2Enabled
101
+ if (config.watchdog.tier2Enabled) {
102
+ try {
103
+ const { store } = openSessionStore(overstoryDir);
104
+ try {
105
+ const sessions = store.getAll();
106
+ const monitorActive = sessions.some(
107
+ (s) => s.capability === "monitor" && s.state !== "completed" && s.state !== "zombie",
108
+ );
109
+ if (!monitorActive) {
110
+ checks.push({
111
+ name: "tier2 monitor",
112
+ category: "watchdog",
113
+ status: "warn",
114
+ message: "Tier 2 monitor is enabled but not running",
115
+ });
116
+ } else {
117
+ checks.push({
118
+ name: "tier2 monitor",
119
+ category: "watchdog",
120
+ status: "pass",
121
+ message: "Tier 2 monitor agent is active",
122
+ });
123
+ }
124
+ } finally {
125
+ store.close();
126
+ }
127
+ } catch {
128
+ checks.push({
129
+ name: "tier2 monitor",
130
+ category: "watchdog",
131
+ status: "warn",
132
+ message: "Tier 2 monitor check skipped — session store unavailable",
133
+ });
134
+ }
135
+ }
136
+
137
+ // Check 6: Tier 1 triage available if tier1Enabled
138
+ if (config.watchdog.tier1Enabled) {
139
+ try {
140
+ getRuntime(config?.runtime?.printCommand ?? config?.runtime?.default, config);
141
+ checks.push({
142
+ name: "tier1 triage",
143
+ category: "watchdog",
144
+ status: "pass",
145
+ message: "Tier 1 triage runtime is available",
146
+ });
147
+ } catch {
148
+ checks.push({
149
+ name: "tier1 triage",
150
+ category: "watchdog",
151
+ status: "warn",
152
+ message: "Tier 1 triage is enabled but runtime is not available",
153
+ });
154
+ }
155
+ }
156
+
157
+ return checks;
158
+ };
@@ -124,7 +124,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
124
124
 
125
125
  const manifest = await loader.load();
126
126
 
127
- // All 7 agents present (supervisor removed: deprecated, use lead instead)
127
+ // All 8 agents present (supervisor removed: deprecated, use lead instead)
128
128
  const agentNames = Object.keys(manifest.agents).sort();
129
129
  expect(agentNames).toEqual([
130
130
  "builder",
@@ -132,6 +132,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
132
132
  "lead",
133
133
  "merger",
134
134
  "monitor",
135
+ "orchestrator",
135
136
  "reviewer",
136
137
  "scout",
137
138
  ]);