@os-eco/overstory-cli 0.6.9 → 0.6.11

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 (49) hide show
  1. package/README.md +161 -265
  2. package/agents/builder.md +6 -15
  3. package/agents/lead.md +13 -6
  4. package/agents/merger.md +5 -13
  5. package/agents/reviewer.md +2 -9
  6. package/package.json +1 -1
  7. package/src/agents/hooks-deployer.test.ts +105 -0
  8. package/src/agents/hooks-deployer.ts +26 -11
  9. package/src/agents/manifest.test.ts +1 -0
  10. package/src/agents/overlay.test.ts +235 -1
  11. package/src/agents/overlay.ts +107 -9
  12. package/src/commands/completions.test.ts +8 -20
  13. package/src/commands/completions.ts +7 -5
  14. package/src/commands/coordinator.ts +4 -4
  15. package/src/commands/doctor.ts +97 -48
  16. package/src/commands/ecosystem.ts +291 -0
  17. package/src/commands/feed.ts +2 -2
  18. package/src/commands/group.ts +4 -4
  19. package/src/commands/mail.test.ts +63 -1
  20. package/src/commands/mail.ts +18 -1
  21. package/src/commands/merge.ts +2 -2
  22. package/src/commands/monitor.ts +2 -2
  23. package/src/commands/sling.test.ts +174 -27
  24. package/src/commands/sling.ts +96 -12
  25. package/src/commands/status.ts +1 -1
  26. package/src/commands/supervisor.ts +4 -4
  27. package/src/commands/trace.ts +2 -2
  28. package/src/commands/upgrade.test.ts +46 -0
  29. package/src/commands/upgrade.ts +259 -0
  30. package/src/config.test.ts +22 -0
  31. package/src/config.ts +12 -0
  32. package/src/doctor/agents.test.ts +1 -0
  33. package/src/doctor/config-check.test.ts +1 -0
  34. package/src/doctor/consistency.test.ts +1 -0
  35. package/src/doctor/databases.test.ts +39 -0
  36. package/src/doctor/databases.ts +7 -10
  37. package/src/doctor/dependencies.test.ts +1 -0
  38. package/src/doctor/ecosystem.test.ts +308 -0
  39. package/src/doctor/ecosystem.ts +155 -0
  40. package/src/doctor/logs.test.ts +1 -0
  41. package/src/doctor/merge-queue.test.ts +99 -0
  42. package/src/doctor/merge-queue.ts +23 -0
  43. package/src/doctor/structure.test.ts +131 -1
  44. package/src/doctor/structure.ts +87 -1
  45. package/src/doctor/types.ts +5 -2
  46. package/src/doctor/version.test.ts +1 -0
  47. package/src/index.ts +29 -4
  48. package/src/types.ts +11 -0
  49. package/templates/overlay.md.tmpl +3 -1
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Tests for the ecosystem doctor check module.
3
+ *
4
+ * We inject a mock spawner instead of using mock.module() to avoid cross-test
5
+ * leakage (see mulch record mx-56558b on why mock.module() is avoided).
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { OverstoryConfig } from "../types.ts";
10
+ import { makeCheckEcosystem, parseSemver } from "./ecosystem.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Minimal config fixture
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const mockConfig: OverstoryConfig = {
17
+ project: {
18
+ name: "test-project",
19
+ root: "/tmp/test",
20
+ canonicalBranch: "main",
21
+ },
22
+ agents: {
23
+ manifestPath: "/tmp/.overstory/agent-manifest.json",
24
+ baseDir: "/tmp/.overstory/agents",
25
+ maxConcurrent: 5,
26
+ staggerDelayMs: 1000,
27
+ maxDepth: 2,
28
+ maxSessionsPerRun: 0,
29
+ maxAgentsPerLead: 5,
30
+ },
31
+ worktrees: {
32
+ baseDir: "/tmp/.overstory/worktrees",
33
+ },
34
+ taskTracker: {
35
+ backend: "auto",
36
+ enabled: false,
37
+ },
38
+ mulch: {
39
+ enabled: false,
40
+ domains: [],
41
+ primeFormat: "markdown",
42
+ },
43
+ merge: {
44
+ aiResolveEnabled: false,
45
+ reimagineEnabled: false,
46
+ },
47
+ providers: {
48
+ anthropic: { type: "native" },
49
+ },
50
+ watchdog: {
51
+ tier0Enabled: false,
52
+ tier0IntervalMs: 30000,
53
+ tier1Enabled: false,
54
+ tier2Enabled: false,
55
+ staleThresholdMs: 300000,
56
+ zombieThresholdMs: 600000,
57
+ nudgeIntervalMs: 60000,
58
+ },
59
+ models: {},
60
+ logging: {
61
+ verbose: false,
62
+ redactSecrets: true,
63
+ },
64
+ };
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Mock spawner helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ type SpawnResponse = { exitCode: number; stdout: string; stderr: string };
71
+
72
+ /**
73
+ * Build a mock spawner that dispatches by binary name (first arg).
74
+ */
75
+ function makeMockSpawner(responses: Record<string, SpawnResponse>) {
76
+ return async (args: string[]): Promise<SpawnResponse> => {
77
+ const bin = args[0] ?? "";
78
+ return (
79
+ responses[bin] ?? {
80
+ exitCode: 127,
81
+ stdout: "",
82
+ stderr: `${bin}: command not found`,
83
+ }
84
+ );
85
+ };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // parseSemver unit tests
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe("parseSemver", () => {
93
+ test("extracts bare semver", () => {
94
+ expect(parseSemver("1.2.3")).toBe("1.2.3");
95
+ });
96
+
97
+ test("extracts semver from prefixed output", () => {
98
+ expect(parseSemver("mulch v1.0.0")).toBe("1.0.0");
99
+ expect(parseSemver("seeds version 2.3.4")).toBe("2.3.4");
100
+ });
101
+
102
+ test("extracts semver with prerelease", () => {
103
+ expect(parseSemver("1.2.3-alpha.1")).toBe("1.2.3-alpha.1");
104
+ });
105
+
106
+ test("extracts semver with build metadata", () => {
107
+ expect(parseSemver("1.2.3+build.42")).toBe("1.2.3+build.42");
108
+ });
109
+
110
+ test("returns null for non-semver strings", () => {
111
+ expect(parseSemver("not-a-version")).toBeNull();
112
+ expect(parseSemver("")).toBeNull();
113
+ expect(parseSemver("v2.0")).toBeNull();
114
+ });
115
+
116
+ test("extracts first semver when multiple exist", () => {
117
+ expect(parseSemver("tool 1.0.0 requires node 18.0.0")).toBe("1.0.0");
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // checkEcosystem integration tests
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("checkEcosystem", () => {
126
+ test("returns exactly 3 checks (one per tool)", async () => {
127
+ const spawner = makeMockSpawner({
128
+ ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
129
+ sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
130
+ cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
131
+ });
132
+ const check = makeCheckEcosystem(spawner);
133
+ const results = await check(mockConfig, "/tmp/.overstory");
134
+
135
+ expect(results).toHaveLength(3);
136
+ });
137
+
138
+ test("check names match tool names", async () => {
139
+ const spawner = makeMockSpawner({
140
+ ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
141
+ sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
142
+ cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
143
+ });
144
+ const check = makeCheckEcosystem(spawner);
145
+ const results = await check(mockConfig, "/tmp/.overstory");
146
+
147
+ const names = results.map((r) => r.name);
148
+ expect(names).toContain("mulch semver");
149
+ expect(names).toContain("seeds semver");
150
+ expect(names).toContain("canopy semver");
151
+ });
152
+
153
+ test("all checks report category 'ecosystem'", async () => {
154
+ const spawner = makeMockSpawner({
155
+ ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
156
+ sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
157
+ cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
158
+ });
159
+ const check = makeCheckEcosystem(spawner);
160
+ const results = await check(mockConfig, "/tmp/.overstory");
161
+
162
+ for (const r of results) {
163
+ expect(r.category).toBe("ecosystem");
164
+ }
165
+ });
166
+
167
+ test("pass when all tools report valid semver", async () => {
168
+ const spawner = makeMockSpawner({
169
+ ml: { exitCode: 0, stdout: "mulch v1.2.3\n", stderr: "" },
170
+ sd: { exitCode: 0, stdout: "seeds 0.5.0\n", stderr: "" },
171
+ cn: { exitCode: 0, stdout: "0.1.0\n", stderr: "" },
172
+ });
173
+ const check = makeCheckEcosystem(spawner);
174
+ const results = await check(mockConfig, "/tmp/.overstory");
175
+
176
+ for (const r of results) {
177
+ expect(r.status).toBe("pass");
178
+ }
179
+ });
180
+
181
+ test("warn when a tool is not available (non-zero exit code)", async () => {
182
+ const spawner = makeMockSpawner({
183
+ ml: { exitCode: 127, stdout: "", stderr: "ml: command not found" },
184
+ sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
185
+ cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
186
+ });
187
+ const check = makeCheckEcosystem(spawner);
188
+ const results = await check(mockConfig, "/tmp/.overstory");
189
+
190
+ const mulch = results.find((r) => r.name === "mulch semver");
191
+ expect(mulch?.status).toBe("warn");
192
+ expect(mulch?.fixable).toBe(true);
193
+ expect(typeof mulch?.fix).toBe("function");
194
+ });
195
+
196
+ test("warn when version output is not valid semver", async () => {
197
+ const spawner = makeMockSpawner({
198
+ ml: { exitCode: 0, stdout: "mulch dev-build\n", stderr: "" },
199
+ sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
200
+ cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
201
+ });
202
+ const check = makeCheckEcosystem(spawner);
203
+ const results = await check(mockConfig, "/tmp/.overstory");
204
+
205
+ const mulch = results.find((r) => r.name === "mulch semver");
206
+ expect(mulch?.status).toBe("warn");
207
+ expect(mulch?.message).toContain("not parseable semver");
208
+ expect(mulch?.fixable).toBe(true);
209
+ });
210
+
211
+ test("passing checks include version in message", async () => {
212
+ const spawner = makeMockSpawner({
213
+ ml: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
214
+ sd: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
215
+ cn: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
216
+ });
217
+ const check = makeCheckEcosystem(spawner);
218
+ const results = await check(mockConfig, "/tmp/.overstory");
219
+
220
+ for (const r of results) {
221
+ expect(r.status).toBe("pass");
222
+ expect(r.message).toContain("1.2.3");
223
+ }
224
+ });
225
+
226
+ test("passing checks include raw output in details", async () => {
227
+ const spawner = makeMockSpawner({
228
+ ml: { exitCode: 0, stdout: "mulch v1.0.0\n", stderr: "" },
229
+ sd: { exitCode: 0, stdout: "seeds 1.0.0\n", stderr: "" },
230
+ cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
231
+ });
232
+ const check = makeCheckEcosystem(spawner);
233
+ const results = await check(mockConfig, "/tmp/.overstory");
234
+
235
+ const mulch = results.find((r) => r.name === "mulch semver");
236
+ expect(mulch?.details).toContain("mulch v1.0.0");
237
+ });
238
+
239
+ test("unavailable tool details include install hint", async () => {
240
+ const spawner = makeMockSpawner({
241
+ ml: { exitCode: 127, stdout: "", stderr: "not found" },
242
+ sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
243
+ cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
244
+ });
245
+ const check = makeCheckEcosystem(spawner);
246
+ const results = await check(mockConfig, "/tmp/.overstory");
247
+
248
+ const mulch = results.find((r) => r.name === "mulch semver");
249
+ const hasHint = mulch?.details?.some((d) => d.includes("@os-eco/mulch-cli"));
250
+ expect(hasHint).toBe(true);
251
+ });
252
+
253
+ test("all checks have required DoctorCheck fields", async () => {
254
+ const spawner = makeMockSpawner({
255
+ ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
256
+ sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
257
+ cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
258
+ });
259
+ const check = makeCheckEcosystem(spawner);
260
+ const results = await check(mockConfig, "/tmp/.overstory");
261
+
262
+ for (const r of results) {
263
+ expect(typeof r.name).toBe("string");
264
+ expect(r.name.length).toBeGreaterThan(0);
265
+ expect(r.category).toBe("ecosystem");
266
+ expect(["pass", "warn", "fail"]).toContain(r.status);
267
+ expect(typeof r.message).toBe("string");
268
+ }
269
+ });
270
+
271
+ test("failing checks are marked fixable and have a fix closure", async () => {
272
+ const spawner = makeMockSpawner({}); // all tools unavailable
273
+ const check = makeCheckEcosystem(spawner);
274
+ const results = await check(mockConfig, "/tmp/.overstory");
275
+
276
+ for (const r of results) {
277
+ expect(r.status).toBe("warn");
278
+ expect(r.fixable).toBe(true);
279
+ expect(typeof r.fix).toBe("function");
280
+ }
281
+ });
282
+
283
+ test("handles version in stderr when stdout is empty", async () => {
284
+ const spawner = makeMockSpawner({
285
+ ml: { exitCode: 0, stdout: "", stderr: "1.0.0" },
286
+ sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
287
+ cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
288
+ });
289
+ const check = makeCheckEcosystem(spawner);
290
+ const results = await check(mockConfig, "/tmp/.overstory");
291
+
292
+ const mulch = results.find((r) => r.name === "mulch semver");
293
+ expect(mulch?.status).toBe("pass");
294
+ });
295
+
296
+ test("handles spawn exception gracefully", async () => {
297
+ const errorSpawner = async (_args: string[]) => {
298
+ throw new Error("spawn failed");
299
+ };
300
+ const check = makeCheckEcosystem(errorSpawner);
301
+ const results = await check(mockConfig, "/tmp/.overstory");
302
+
303
+ expect(results).toHaveLength(3);
304
+ for (const r of results) {
305
+ expect(r.status).toBe("warn");
306
+ }
307
+ });
308
+ });
@@ -0,0 +1,155 @@
1
+ import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
2
+
3
+ /**
4
+ * Ecosystem health checks.
5
+ *
6
+ * Validates that os-eco CLI tools (ml, sd, cn) are on PATH and report valid
7
+ * semver versions. Intentionally does NOT duplicate the availability checks in
8
+ * dependencies.ts — those confirm the binaries exist. These checks focus on
9
+ * whether the reported version string is parseable semver, and whether the
10
+ * tools are mutually compatible.
11
+ *
12
+ * Fix closures reinstall the relevant package via `bun install -g <pkg>`.
13
+ */
14
+
15
+ /** A single os-eco ecosystem tool. */
16
+ interface EcosystemTool {
17
+ /** Human-readable tool name. */
18
+ name: string;
19
+ /** Primary binary to invoke for version check. */
20
+ bin: string;
21
+ /** npm package name for install / reinstall. */
22
+ pkg: string;
23
+ }
24
+
25
+ const ECOSYSTEM_TOOLS: EcosystemTool[] = [
26
+ { name: "mulch", bin: "ml", pkg: "@os-eco/mulch-cli" },
27
+ { name: "seeds", bin: "sd", pkg: "@os-eco/seeds-cli" },
28
+ { name: "canopy", bin: "cn", pkg: "@os-eco/canopy-cli" },
29
+ ];
30
+
31
+ /** Spawner abstraction — injected in tests, uses Bun.spawn in production. */
32
+ export type Spawner = (
33
+ args: string[],
34
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
35
+
36
+ async function defaultSpawner(
37
+ args: string[],
38
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
39
+ const proc = Bun.spawn(args, {
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ });
43
+ const exitCode = await proc.exited;
44
+ const stdout = await new Response(proc.stdout).text();
45
+ const stderr = await new Response(proc.stderr).text();
46
+ return { exitCode, stdout, stderr };
47
+ }
48
+
49
+ /**
50
+ * Loose semver extractor.
51
+ * Finds the first x.y.z (optionally x.y.z-pre or x.y.z+build) token in a string.
52
+ * Returns null when no valid semver token is found.
53
+ */
54
+ export function parseSemver(output: string): string | null {
55
+ const match = /(\d+\.\d+\.\d+(?:[-.+][a-zA-Z0-9._-]*)?)/.exec(output);
56
+ return match?.[1] ?? null;
57
+ }
58
+
59
+ /** Internal result of probing a binary's version output. */
60
+ interface VersionProbeResult {
61
+ available: boolean;
62
+ version: string | null;
63
+ raw: string;
64
+ }
65
+
66
+ async function probeVersion(bin: string, spawner: Spawner): Promise<VersionProbeResult> {
67
+ try {
68
+ const { exitCode, stdout, stderr } = await spawner([bin, "--version"]);
69
+ const raw = (stdout + stderr).trim();
70
+ if (exitCode !== 0) {
71
+ return { available: false, version: null, raw };
72
+ }
73
+ const version = parseSemver(raw);
74
+ return { available: true, version, raw };
75
+ } catch {
76
+ return { available: false, version: null, raw: "" };
77
+ }
78
+ }
79
+
80
+ /** Build a DoctorCheck for a single ecosystem tool. */
81
+ function buildCheck(tool: EcosystemTool, probe: VersionProbeResult): DoctorCheck {
82
+ const { bin, pkg, name } = tool;
83
+
84
+ if (!probe.available) {
85
+ return {
86
+ name: `${name} semver`,
87
+ category: "ecosystem",
88
+ status: "warn",
89
+ message: `${bin} is not available — cannot verify version`,
90
+ details: [`Install: bun install -g ${pkg}`],
91
+ fixable: true,
92
+ fix: async () => {
93
+ const proc = Bun.spawn(["bun", "install", "-g", pkg], {
94
+ stdout: "inherit",
95
+ stderr: "inherit",
96
+ });
97
+ await proc.exited;
98
+ return [`Installed ${pkg}`];
99
+ },
100
+ };
101
+ }
102
+
103
+ if (probe.version === null) {
104
+ return {
105
+ name: `${name} semver`,
106
+ category: "ecosystem",
107
+ status: "warn",
108
+ message: `${bin} --version output is not parseable semver`,
109
+ details: [
110
+ `Raw output: ${probe.raw || "(empty)"}`,
111
+ "Expected format: x.y.z",
112
+ `Reinstall: bun install -g ${pkg}`,
113
+ ],
114
+ fixable: true,
115
+ fix: async () => {
116
+ const proc = Bun.spawn(["bun", "install", "-g", pkg], {
117
+ stdout: "inherit",
118
+ stderr: "inherit",
119
+ });
120
+ await proc.exited;
121
+ return [`Reinstalled ${pkg}`];
122
+ },
123
+ };
124
+ }
125
+
126
+ return {
127
+ name: `${name} semver`,
128
+ category: "ecosystem",
129
+ status: "pass",
130
+ message: `${name} v${probe.version} (valid semver)`,
131
+ details: [probe.raw],
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Factory that creates a DoctorCheckFn with an injectable spawner.
137
+ * Used for testing without module-level mocks.
138
+ */
139
+ export function makeCheckEcosystem(spawner: Spawner = defaultSpawner): DoctorCheckFn {
140
+ return async (_config, _overstoryDir): Promise<DoctorCheck[]> => {
141
+ const checks: DoctorCheck[] = [];
142
+
143
+ for (const tool of ECOSYSTEM_TOOLS) {
144
+ const probe = await probeVersion(tool.bin, spawner);
145
+ checks.push(buildCheck(tool, probe));
146
+ }
147
+
148
+ return checks;
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Ecosystem health check — validates semver version output for ml, sd, cn.
154
+ */
155
+ export const checkEcosystem: DoctorCheckFn = makeCheckEcosystem();
@@ -36,6 +36,7 @@ describe("checkLogs", () => {
36
36
  staggerDelayMs: 1000,
37
37
  maxDepth: 2,
38
38
  maxSessionsPerRun: 0,
39
+ maxAgentsPerLead: 5,
39
40
  },
40
41
  worktrees: {
41
42
  baseDir: ".overstory/worktrees",
@@ -23,6 +23,7 @@ describe("checkMergeQueue", () => {
23
23
  staggerDelayMs: 100,
24
24
  maxDepth: 2,
25
25
  maxSessionsPerRun: 0,
26
+ maxAgentsPerLead: 5,
26
27
  },
27
28
  worktrees: { baseDir: "" },
28
29
  taskTracker: { backend: "auto", enabled: true },
@@ -213,4 +214,102 @@ describe("checkMergeQueue", () => {
213
214
  expect(duplicateCheck?.message).toContain("duplicate branch entries");
214
215
  expect(duplicateCheck?.details?.[0]).toContain("feature/duplicate");
215
216
  });
217
+
218
+ test("fix() deletes stale pending entries", () => {
219
+ const dbPath = join(tempDir, "merge-queue.db");
220
+ const queue = createMergeQueue(dbPath);
221
+ queue.close();
222
+
223
+ const staleDate = new Date();
224
+ staleDate.setDate(staleDate.getDate() - 2); // 2 days ago
225
+
226
+ const db = new Database(dbPath);
227
+ db.prepare(
228
+ "INSERT INTO merge_queue (branch_name, task_id, agent_name, files_modified, status, enqueued_at) VALUES (?, ?, ?, ?, ?, ?)",
229
+ ).run(
230
+ "feature/stale-1",
231
+ "beads-abc",
232
+ "test-agent",
233
+ JSON.stringify(["src/test.ts"]),
234
+ "pending",
235
+ staleDate.toISOString(),
236
+ );
237
+ db.prepare(
238
+ "INSERT INTO merge_queue (branch_name, task_id, agent_name, files_modified, status, enqueued_at) VALUES (?, ?, ?, ?, ?, ?)",
239
+ ).run(
240
+ "feature/stale-2",
241
+ "beads-def",
242
+ "test-agent",
243
+ JSON.stringify(["src/other.ts"]),
244
+ "merging",
245
+ staleDate.toISOString(),
246
+ );
247
+ db.close();
248
+
249
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
250
+
251
+ const staleCheck = checks.find((c) => c?.name === "merge-queue.db staleness");
252
+ expect(staleCheck?.fix).toBeDefined();
253
+
254
+ const actions = staleCheck?.fix?.();
255
+ expect(Array.isArray(actions)).toBe(true);
256
+ const actionsArr = actions as string[];
257
+ expect(actionsArr.some((a) => a.includes("Deleted") && a.includes("stale"))).toBe(true);
258
+
259
+ // Verify entries were deleted
260
+ const verifyDb = new Database(dbPath);
261
+ const remaining = verifyDb
262
+ .prepare("SELECT COUNT(*) as count FROM merge_queue WHERE status IN ('pending', 'merging')")
263
+ .get() as { count: number };
264
+ verifyDb.close();
265
+ expect(remaining.count).toBe(0);
266
+ });
267
+
268
+ test("fix() removes duplicate entries keeping newest", () => {
269
+ const dbPath = join(tempDir, "merge-queue.db");
270
+ const queue = createMergeQueue(dbPath);
271
+ queue.enqueue({
272
+ branchName: "feature/dup",
273
+ taskId: "beads-abc",
274
+ agentName: "agent-1",
275
+ filesModified: ["src/a.ts"],
276
+ });
277
+ queue.enqueue({
278
+ branchName: "feature/dup",
279
+ taskId: "beads-def",
280
+ agentName: "agent-2",
281
+ filesModified: ["src/b.ts"],
282
+ });
283
+ queue.enqueue({
284
+ branchName: "feature/other",
285
+ taskId: "beads-ghi",
286
+ agentName: "agent-3",
287
+ filesModified: ["src/c.ts"],
288
+ });
289
+ queue.close();
290
+
291
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
292
+
293
+ const dupCheck = checks.find((c) => c?.name === "merge-queue.db duplicates");
294
+ expect(dupCheck?.fix).toBeDefined();
295
+
296
+ const actions = dupCheck?.fix?.();
297
+ expect(Array.isArray(actions)).toBe(true);
298
+ const actionsArr = actions as string[];
299
+ expect(actionsArr.some((a) => a.includes("Removed") && a.includes("duplicate"))).toBe(true);
300
+
301
+ // Verify only 2 entries remain (1 per branch)
302
+ const verifyDb = new Database(dbPath);
303
+ const remaining = verifyDb.prepare("SELECT COUNT(*) as count FROM merge_queue").get() as {
304
+ count: number;
305
+ };
306
+ // Check the newest entry for feature/dup is kept (highest id)
307
+ const dupEntries = verifyDb
308
+ .prepare("SELECT agent_name FROM merge_queue WHERE branch_name = 'feature/dup'")
309
+ .all() as Array<{ agent_name: string }>;
310
+ verifyDb.close();
311
+ expect(remaining.count).toBe(2);
312
+ expect(dupEntries).toHaveLength(1);
313
+ expect(dupEntries[0]?.agent_name).toBe("agent-2"); // newest entry kept
314
+ });
216
315
  });
@@ -114,6 +114,18 @@ export const checkMergeQueue: DoctorCheckFn = (_config, overstoryDir): DoctorChe
114
114
  message: `Found ${staleEntries.length} potentially stale queue entries`,
115
115
  details: staleEntries,
116
116
  fixable: true,
117
+ fix: () => {
118
+ const fixDb = new Database(dbPath);
119
+ fixDb.exec("PRAGMA busy_timeout=5000");
120
+ const staleThreshold = new Date(now.getTime() - staleThresholdMs).toISOString();
121
+ const result = fixDb
122
+ .prepare(
123
+ "DELETE FROM merge_queue WHERE status IN ('pending', 'merging') AND enqueued_at < ?",
124
+ )
125
+ .run(staleThreshold);
126
+ fixDb.close();
127
+ return [`Deleted ${result.changes} stale merge queue entries`];
128
+ },
117
129
  });
118
130
  }
119
131
 
@@ -134,6 +146,17 @@ export const checkMergeQueue: DoctorCheckFn = (_config, overstoryDir): DoctorChe
134
146
  message: "Found duplicate branch entries in queue",
135
147
  details: duplicates,
136
148
  fixable: true,
149
+ fix: () => {
150
+ const fixDb = new Database(dbPath);
151
+ fixDb.exec("PRAGMA busy_timeout=5000");
152
+ const result = fixDb
153
+ .prepare(
154
+ "DELETE FROM merge_queue WHERE id NOT IN (SELECT MAX(id) FROM merge_queue GROUP BY branch_name)",
155
+ )
156
+ .run();
157
+ fixDb.close();
158
+ return [`Removed ${result.changes} duplicate merge queue entries`];
159
+ },
137
160
  });
138
161
  }
139
162
  } finally {