@os-eco/overstory-cli 0.6.1 → 0.6.5

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 (110) hide show
  1. package/README.md +8 -7
  2. package/package.json +12 -4
  3. package/src/agents/checkpoint.test.ts +2 -2
  4. package/src/agents/hooks-deployer.test.ts +131 -16
  5. package/src/agents/hooks-deployer.ts +33 -1
  6. package/src/agents/identity.test.ts +27 -27
  7. package/src/agents/identity.ts +10 -10
  8. package/src/agents/lifecycle.test.ts +6 -6
  9. package/src/agents/lifecycle.ts +2 -2
  10. package/src/agents/manifest.test.ts +86 -0
  11. package/src/agents/overlay.test.ts +9 -9
  12. package/src/agents/overlay.ts +4 -4
  13. package/src/commands/agents.test.ts +8 -8
  14. package/src/commands/agents.ts +62 -91
  15. package/src/commands/clean.test.ts +36 -51
  16. package/src/commands/clean.ts +28 -49
  17. package/src/commands/completions.ts +14 -0
  18. package/src/commands/coordinator.test.ts +133 -26
  19. package/src/commands/coordinator.ts +101 -64
  20. package/src/commands/costs.test.ts +47 -47
  21. package/src/commands/costs.ts +96 -75
  22. package/src/commands/dashboard.test.ts +2 -2
  23. package/src/commands/dashboard.ts +75 -95
  24. package/src/commands/doctor.test.ts +2 -2
  25. package/src/commands/doctor.ts +92 -79
  26. package/src/commands/errors.test.ts +2 -2
  27. package/src/commands/errors.ts +56 -50
  28. package/src/commands/feed.test.ts +2 -2
  29. package/src/commands/feed.ts +86 -83
  30. package/src/commands/group.ts +167 -177
  31. package/src/commands/hooks.test.ts +2 -2
  32. package/src/commands/hooks.ts +52 -42
  33. package/src/commands/init.test.ts +19 -19
  34. package/src/commands/init.ts +7 -16
  35. package/src/commands/inspect.test.ts +18 -18
  36. package/src/commands/inspect.ts +55 -58
  37. package/src/commands/log.test.ts +26 -31
  38. package/src/commands/log.ts +97 -91
  39. package/src/commands/logs.test.ts +1 -1
  40. package/src/commands/logs.ts +101 -104
  41. package/src/commands/mail.test.ts +5 -5
  42. package/src/commands/mail.ts +157 -169
  43. package/src/commands/merge.test.ts +28 -66
  44. package/src/commands/merge.ts +21 -51
  45. package/src/commands/metrics.test.ts +8 -8
  46. package/src/commands/metrics.ts +34 -35
  47. package/src/commands/monitor.test.ts +3 -3
  48. package/src/commands/monitor.ts +57 -62
  49. package/src/commands/nudge.test.ts +1 -1
  50. package/src/commands/nudge.ts +41 -89
  51. package/src/commands/prime.test.ts +19 -51
  52. package/src/commands/prime.ts +13 -50
  53. package/src/commands/replay.test.ts +2 -2
  54. package/src/commands/replay.ts +79 -86
  55. package/src/commands/run.test.ts +1 -1
  56. package/src/commands/run.ts +97 -77
  57. package/src/commands/sling.test.ts +201 -5
  58. package/src/commands/sling.ts +37 -64
  59. package/src/commands/spec.test.ts +14 -40
  60. package/src/commands/spec.ts +32 -101
  61. package/src/commands/status.test.ts +97 -1
  62. package/src/commands/status.ts +63 -58
  63. package/src/commands/stop.test.ts +22 -40
  64. package/src/commands/stop.ts +18 -33
  65. package/src/commands/supervisor.test.ts +12 -14
  66. package/src/commands/supervisor.ts +144 -165
  67. package/src/commands/trace.test.ts +15 -15
  68. package/src/commands/trace.ts +59 -82
  69. package/src/commands/watch.test.ts +2 -2
  70. package/src/commands/watch.ts +38 -45
  71. package/src/commands/worktree.test.ts +213 -37
  72. package/src/commands/worktree.ts +110 -55
  73. package/src/config.test.ts +96 -0
  74. package/src/doctor/consistency.test.ts +14 -14
  75. package/src/doctor/databases.test.ts +22 -2
  76. package/src/doctor/databases.ts +16 -0
  77. package/src/doctor/dependencies.test.ts +55 -1
  78. package/src/doctor/dependencies.ts +113 -18
  79. package/src/doctor/merge-queue.test.ts +4 -4
  80. package/src/e2e/init-sling-lifecycle.test.ts +8 -8
  81. package/src/errors.ts +1 -1
  82. package/src/index.ts +223 -213
  83. package/src/logging/color.test.ts +74 -91
  84. package/src/logging/color.ts +52 -46
  85. package/src/logging/reporter.test.ts +10 -10
  86. package/src/logging/reporter.ts +6 -5
  87. package/src/mail/broadcast.test.ts +1 -1
  88. package/src/mail/client.test.ts +6 -6
  89. package/src/mail/store.test.ts +3 -3
  90. package/src/merge/queue.test.ts +73 -7
  91. package/src/merge/queue.ts +17 -2
  92. package/src/merge/resolver.test.ts +159 -7
  93. package/src/merge/resolver.ts +46 -2
  94. package/src/metrics/store.test.ts +44 -44
  95. package/src/metrics/store.ts +2 -2
  96. package/src/metrics/summary.test.ts +35 -35
  97. package/src/mulch/client.test.ts +1 -1
  98. package/src/schema-consistency.test.ts +239 -0
  99. package/src/sessions/compat.test.ts +3 -3
  100. package/src/sessions/compat.ts +2 -2
  101. package/src/sessions/store.test.ts +41 -4
  102. package/src/sessions/store.ts +13 -2
  103. package/src/types.ts +14 -14
  104. package/src/watchdog/daemon.test.ts +10 -10
  105. package/src/watchdog/daemon.ts +1 -1
  106. package/src/watchdog/health.test.ts +1 -1
  107. package/src/worktree/manager.test.ts +20 -20
  108. package/src/worktree/manager.ts +120 -4
  109. package/src/worktree/tmux.test.ts +98 -9
  110. package/src/worktree/tmux.ts +18 -0
@@ -7,18 +7,20 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
+ import { Command } from "commander";
10
11
  import { loadConfig } from "../config.ts";
11
12
  import { ValidationError } from "../errors.ts";
12
13
  import { createMailStore } from "../mail/store.ts";
13
14
  import { openSessionStore } from "../sessions/compat.ts";
14
15
  import type { AgentSession } from "../types.ts";
15
- import { isBranchMerged, listWorktrees, removeWorktree } from "../worktree/manager.ts";
16
+ import {
17
+ isBranchMerged,
18
+ listWorktrees,
19
+ preserveSeedsChanges,
20
+ removeWorktree,
21
+ } from "../worktree/manager.ts";
16
22
  import { isSessionAlive, killSession } from "../worktree/tmux.ts";
17
23
 
18
- function hasFlag(args: string[], flag: string): boolean {
19
- return args.includes(flag);
20
- }
21
-
22
24
  /**
23
25
  * Handle `overstory worktree list`.
24
26
  */
@@ -44,7 +46,7 @@ async function handleList(root: string, json: boolean): Promise<void> {
44
46
  head: wt.head,
45
47
  agentName: session?.agentName ?? null,
46
48
  state: session?.state ?? null,
47
- beadId: session?.beadId ?? null,
49
+ taskId: session?.taskId ?? null,
48
50
  };
49
51
  });
50
52
  process.stdout.write(`${JSON.stringify(entries, null, "\t")}\n`);
@@ -61,7 +63,7 @@ async function handleList(root: string, json: boolean): Promise<void> {
61
63
  const session = sessions.find((s) => s.worktreePath === wt.path);
62
64
  const state = session?.state ?? "unknown";
63
65
  const agent = session?.agentName ?? "?";
64
- const bead = session?.beadId ?? "?";
66
+ const bead = session?.taskId ?? "?";
65
67
  process.stdout.write(` ${wt.branch}\n`);
66
68
  process.stdout.write(` Agent: ${agent} | State: ${state} | Task: ${bead}\n`);
67
69
  process.stdout.write(` Path: ${wt.path}\n\n`);
@@ -72,14 +74,12 @@ async function handleList(root: string, json: boolean): Promise<void> {
72
74
  * Handle `overstory worktree clean [--completed] [--all] [--force]`.
73
75
  */
74
76
  async function handleClean(
75
- args: string[],
77
+ opts: { all: boolean; force: boolean; completedOnly: boolean },
76
78
  root: string,
77
79
  json: boolean,
78
80
  canonicalBranch: string,
79
81
  ): Promise<void> {
80
- const all = hasFlag(args, "--all");
81
- const force = hasFlag(args, "--force");
82
- const completedOnly = hasFlag(args, "--completed") || !all;
82
+ const { force, completedOnly } = opts;
83
83
 
84
84
  const worktrees = await listWorktrees(root);
85
85
  const overstoryDir = join(root, ".overstory");
@@ -97,6 +97,7 @@ async function handleClean(
97
97
  const cleaned: string[] = [];
98
98
  const failed: string[] = [];
99
99
  const skipped: string[] = [];
100
+ const seedsPreserved: string[] = [];
100
101
 
101
102
  try {
102
103
  for (const wt of overstoryWts) {
@@ -107,8 +108,11 @@ async function handleClean(
107
108
  continue;
108
109
  }
109
110
 
110
- // Check if the branch has been merged into the canonical branch (unless --force)
111
- if (!force && wt.branch.length > 0) {
111
+ // Lead branches are never merged via the normal pipeline skip merge check for leads.
112
+ const isLead = session?.capability === "lead";
113
+
114
+ // Check if the branch has been merged into the canonical branch (unless --force or lead)
115
+ if (!force && !isLead && wt.branch.length > 0) {
112
116
  let merged = false;
113
117
  try {
114
118
  merged = await isBranchMerged(root, wt.branch, canonicalBranch);
@@ -136,8 +140,8 @@ async function handleClean(
136
140
  }
137
141
  }
138
142
 
139
- // Warn about force-deleting unmerged branch
140
- if (force && wt.branch.length > 0) {
143
+ // Warn about force-deleting unmerged branch (non-lead only)
144
+ if (force && !isLead && wt.branch.length > 0) {
141
145
  let merged = false;
142
146
  try {
143
147
  merged = await isBranchMerged(root, wt.branch, canonicalBranch);
@@ -149,6 +153,29 @@ async function handleClean(
149
153
  }
150
154
  }
151
155
 
156
+ // Preserve .seeds/ changes from lead worktrees before removal.
157
+ // Lead branches are never merged, so .seeds/ files would otherwise be lost.
158
+ if (isLead && wt.branch.length > 0) {
159
+ const result = await preserveSeedsChanges(
160
+ root,
161
+ wt.branch,
162
+ canonicalBranch,
163
+ session?.agentName ?? "unknown-lead",
164
+ );
165
+ if (result.preserved) {
166
+ seedsPreserved.push(wt.branch);
167
+ if (!json) {
168
+ process.stdout.write(
169
+ `🌱 Preserved .seeds/ changes from lead ${session?.agentName ?? "unknown-lead"}\n`,
170
+ );
171
+ }
172
+ } else if (result.error) {
173
+ process.stderr.write(
174
+ `⚠️ Failed to preserve .seeds/ from ${wt.branch}: ${result.error}\n`,
175
+ );
176
+ }
177
+ }
178
+
152
179
  // Remove worktree and its branch.
153
180
  // Always force worktree removal since deployed .claude/ files create untracked
154
181
  // files that cause non-forced removal to fail.
@@ -215,13 +242,14 @@ async function handleClean(
215
242
 
216
243
  if (json) {
217
244
  process.stdout.write(
218
- `${JSON.stringify({ cleaned, failed, skipped, pruned: pruneCount, mailPurged })}\n`,
245
+ `${JSON.stringify({ cleaned, failed, skipped, pruned: pruneCount, mailPurged, seedsPreserved })}\n`,
219
246
  );
220
247
  } else if (
221
248
  cleaned.length === 0 &&
222
249
  pruneCount === 0 &&
223
250
  failed.length === 0 &&
224
- skipped.length === 0
251
+ skipped.length === 0 &&
252
+ seedsPreserved.length === 0
225
253
  ) {
226
254
  process.stdout.write("No worktrees to clean.\n");
227
255
  } else {
@@ -245,6 +273,11 @@ async function handleClean(
245
273
  `Pruned ${pruneCount} zombie session${pruneCount === 1 ? "" : "s"} from store.\n`,
246
274
  );
247
275
  }
276
+ if (seedsPreserved.length > 0) {
277
+ process.stdout.write(
278
+ `Preserved .seeds/ changes from ${seedsPreserved.length} lead${seedsPreserved.length === 1 ? "" : "s"}.\n`,
279
+ );
280
+ }
248
281
  if (skipped.length > 0) {
249
282
  process.stdout.write(
250
283
  `\n⚠️ Skipped ${skipped.length} worktree${skipped.length === 1 ? "" : "s"} with unmerged branches:\n`,
@@ -260,52 +293,74 @@ async function handleClean(
260
293
  }
261
294
  }
262
295
 
296
+ export function createWorktreeCommand(): Command {
297
+ const cmd = new Command("worktree").description("Manage agent worktrees");
298
+
299
+ cmd
300
+ .command("list")
301
+ .description("List worktrees with agent status")
302
+ .option("--json", "Output as JSON")
303
+ .action(async (opts: { json?: boolean }) => {
304
+ const cwd = process.cwd();
305
+ const config = await loadConfig(cwd);
306
+ await handleList(config.project.root, opts.json ?? false);
307
+ });
308
+
309
+ cmd
310
+ .command("clean")
311
+ .description("Remove completed worktrees")
312
+ .option("--completed", "Only finished agents (default)")
313
+ .option("--all", "Force remove all worktrees")
314
+ .option("--force", "Delete even if branches are unmerged")
315
+ .option("--json", "Output as JSON")
316
+ .action(
317
+ async (opts: { completed?: boolean; all?: boolean; force?: boolean; json?: boolean }) => {
318
+ const cwd = process.cwd();
319
+ const config = await loadConfig(cwd);
320
+ const all = opts.all ?? false;
321
+ await handleClean(
322
+ {
323
+ all,
324
+ force: opts.force ?? false,
325
+ completedOnly: opts.completed ?? !all,
326
+ },
327
+ config.project.root,
328
+ opts.json ?? false,
329
+ config.project.canonicalBranch,
330
+ );
331
+ },
332
+ );
333
+
334
+ return cmd;
335
+ }
336
+
263
337
  /**
264
338
  * Entry point for `overstory worktree <subcommand> [flags]`.
265
339
  *
266
340
  * Subcommands: list, clean.
267
341
  */
268
- const WORKTREE_HELP = `overstory worktree — Manage agent worktrees
269
-
270
- Usage: overstory worktree <subcommand> [flags]
271
-
272
- Subcommands:
273
- list List worktrees with agent status
274
- clean Remove completed worktrees
275
- [--completed] Only finished agents (default)
276
- [--all] Force remove all
277
- [--force] Delete even if branches are unmerged
278
-
279
- Options:
280
- --json Output as JSON
281
- --help, -h Show this help`;
282
-
283
342
  export async function worktreeCommand(args: string[]): Promise<void> {
284
- if (args.includes("--help") || args.includes("-h")) {
285
- process.stdout.write(`${WORKTREE_HELP}\n`);
343
+ const cmd = createWorktreeCommand();
344
+ cmd.exitOverride();
345
+
346
+ if (args.length === 0) {
347
+ process.stdout.write(cmd.helpInformation());
286
348
  return;
287
349
  }
288
350
 
289
- const subcommand = args[0];
290
- const subArgs = args.slice(1);
291
- const jsonFlag = hasFlag(args, "--json");
292
-
293
- const cwd = process.cwd();
294
- const config = await loadConfig(cwd);
295
- const root = config.project.root;
296
- const canonicalBranch = config.project.canonicalBranch;
297
-
298
- switch (subcommand) {
299
- case "list":
300
- await handleList(root, jsonFlag);
301
- break;
302
- case "clean":
303
- await handleClean(subArgs, root, jsonFlag, canonicalBranch);
304
- break;
305
- default:
306
- throw new ValidationError(
307
- `Unknown worktree subcommand: ${subcommand ?? "(none)"}. Use: list, clean`,
308
- { field: "subcommand" },
309
- );
351
+ try {
352
+ await cmd.parseAsync(args, { from: "user" });
353
+ } catch (err: unknown) {
354
+ if (err && typeof err === "object" && "code" in err) {
355
+ const code = (err as { code: string }).code;
356
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
357
+ return;
358
+ }
359
+ if (code === "commander.unknownCommand") {
360
+ const message = err instanceof Error ? err.message : String(err);
361
+ throw new ValidationError(message, { field: "subcommand" });
362
+ }
363
+ }
364
+ throw err;
310
365
  }
311
366
  }
@@ -265,6 +265,80 @@ providers:
265
265
  expect(config.providers.anthropic).toEqual({ type: "native" });
266
266
  });
267
267
 
268
+ test("multiple providers parsed correctly", async () => {
269
+ await ensureOverstoryDir();
270
+ await writeConfig(`
271
+ providers:
272
+ anthropic:
273
+ type: native
274
+ openrouter:
275
+ type: gateway
276
+ baseUrl: https://openrouter.ai/api/v1
277
+ authTokenEnv: OPENROUTER_API_KEY
278
+ litellm:
279
+ type: gateway
280
+ baseUrl: http://localhost:4000
281
+ authTokenEnv: LITELLM_API_KEY
282
+ `);
283
+ const config = await loadConfig(tempDir);
284
+ expect(Object.keys(config.providers).length).toBe(3);
285
+ expect(config.providers.anthropic).toEqual({ type: "native" });
286
+ expect(config.providers.openrouter).toEqual({
287
+ type: "gateway",
288
+ baseUrl: "https://openrouter.ai/api/v1",
289
+ authTokenEnv: "OPENROUTER_API_KEY",
290
+ });
291
+ expect(config.providers.litellm).toEqual({
292
+ type: "gateway",
293
+ baseUrl: "http://localhost:4000",
294
+ authTokenEnv: "LITELLM_API_KEY",
295
+ });
296
+ });
297
+
298
+ test("config.local.yaml adds new provider alongside config.yaml providers", async () => {
299
+ await ensureOverstoryDir();
300
+ await writeConfig(`
301
+ providers:
302
+ openrouter:
303
+ type: gateway
304
+ baseUrl: https://openrouter.ai/api/v1
305
+ authTokenEnv: OPENROUTER_API_KEY
306
+ `);
307
+ await Bun.write(
308
+ join(tempDir, ".overstory", "config.local.yaml"),
309
+ `providers:\n litellm:\n type: gateway\n baseUrl: http://localhost:4000\n authTokenEnv: LITELLM_API_KEY\n`,
310
+ );
311
+ const config = await loadConfig(tempDir);
312
+ // All three providers present: default anthropic + openrouter from config.yaml + litellm from config.local.yaml
313
+ expect(config.providers.anthropic).toEqual({ type: "native" });
314
+ expect(config.providers.openrouter).toEqual({
315
+ type: "gateway",
316
+ baseUrl: "https://openrouter.ai/api/v1",
317
+ authTokenEnv: "OPENROUTER_API_KEY",
318
+ });
319
+ expect(config.providers.litellm).toEqual({
320
+ type: "gateway",
321
+ baseUrl: "http://localhost:4000",
322
+ authTokenEnv: "LITELLM_API_KEY",
323
+ });
324
+ });
325
+
326
+ test("simple model strings still work without providers section", async () => {
327
+ await ensureOverstoryDir();
328
+ await writeConfig(`
329
+ models:
330
+ coordinator: sonnet
331
+ builder: opus
332
+ monitor: haiku
333
+ `);
334
+ const config = await loadConfig(tempDir);
335
+ expect(config.models.coordinator).toBe("sonnet");
336
+ expect(config.models.builder).toBe("opus");
337
+ expect(config.models.monitor).toBe("haiku");
338
+ // Default anthropic provider still present even without explicit providers section
339
+ expect(config.providers.anthropic).toEqual({ type: "native" });
340
+ });
341
+
268
342
  test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
269
343
  await ensureOverstoryDir();
270
344
  await writeConfig(`
@@ -556,6 +630,28 @@ models:
556
630
  await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
557
631
  });
558
632
 
633
+ test("rejects model ref with deeply nested slashes when provider unknown", async () => {
634
+ await writeConfig(`
635
+ models:
636
+ coordinator: unknown/openai/gpt-5.3/latest
637
+ `);
638
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
639
+ });
640
+
641
+ test("accepts model ref with deeply nested slashes when provider exists", async () => {
642
+ await writeConfig(`
643
+ providers:
644
+ openrouter:
645
+ type: gateway
646
+ baseUrl: https://openrouter.ai/api/v1
647
+ authTokenEnv: OPENROUTER_API_KEY
648
+ models:
649
+ coordinator: openrouter/openai/gpt-5.3/variant
650
+ `);
651
+ const config = await loadConfig(tempDir);
652
+ expect(config.models.coordinator).toBe("openrouter/openai/gpt-5.3/variant");
653
+ });
654
+
559
655
  test("rejects bare invalid model name", async () => {
560
656
  await writeConfig(`
561
657
  models:
@@ -195,7 +195,7 @@ describe("checkConsistency", () => {
195
195
  capability: "builder",
196
196
  worktreePath: join(overstoryDir, "worktrees", "dead-agent"),
197
197
  branchName: "overstory/dead-agent/test-123",
198
- beadId: "test-123",
198
+ taskId: "test-123",
199
199
  tmuxSession: "overstory-testproject-dead-agent",
200
200
  state: "working",
201
201
  pid: 99999, // Non-existent PID
@@ -231,7 +231,7 @@ describe("checkConsistency", () => {
231
231
  capability: "builder",
232
232
  worktreePath: join(overstoryDir, "worktrees", "live-agent"),
233
233
  branchName: "overstory/live-agent/test-123",
234
- beadId: "test-123",
234
+ taskId: "test-123",
235
235
  tmuxSession: "overstory-testproject-live-agent",
236
236
  state: "working",
237
237
  pid: 12345,
@@ -266,7 +266,7 @@ describe("checkConsistency", () => {
266
266
  capability: "builder",
267
267
  worktreePath: missingWorktreePath,
268
268
  branchName: "overstory/missing-agent/test-123",
269
- beadId: "test-123",
269
+ taskId: "test-123",
270
270
  tmuxSession: "overstory-testproject-missing-agent",
271
271
  state: "working",
272
272
  pid: null,
@@ -302,7 +302,7 @@ describe("checkConsistency", () => {
302
302
  capability: "builder",
303
303
  worktreePath,
304
304
  branchName: "overstory/agent-without-tmux/test-123",
305
- beadId: "test-123",
305
+ taskId: "test-123",
306
306
  tmuxSession: "overstory-testproject-agent-without-tmux",
307
307
  state: "working",
308
308
  pid: null,
@@ -341,7 +341,7 @@ describe("checkConsistency", () => {
341
341
  capability: "builder",
342
342
  worktreePath,
343
343
  branchName: "overstory/consistent-agent/test-123",
344
- beadId: "test-123",
344
+ taskId: "test-123",
345
345
  tmuxSession: "overstory-testproject-consistent-agent",
346
346
  state: "working",
347
347
  pid: 12345,
@@ -414,7 +414,7 @@ describe("checkConsistency", () => {
414
414
  capability: "builder",
415
415
  worktreePath: join(overstoryDir, "worktrees", "builder-1"),
416
416
  branchName: "overstory/builder-1/test-123",
417
- beadId: "test-123",
417
+ taskId: "test-123",
418
418
  tmuxSession: "overstory-testproject-builder-1",
419
419
  state: "working",
420
420
  pid: null,
@@ -433,7 +433,7 @@ describe("checkConsistency", () => {
433
433
  capability: "builder",
434
434
  worktreePath: join(overstoryDir, "worktrees", "builder-2"),
435
435
  branchName: "overstory/builder-2/test-456",
436
- beadId: "test-456",
436
+ taskId: "test-456",
437
437
  tmuxSession: "overstory-testproject-builder-2",
438
438
  state: "working",
439
439
  pid: null,
@@ -469,7 +469,7 @@ describe("checkConsistency", () => {
469
469
  capability: "builder",
470
470
  worktreePath: join(overstoryDir, "worktrees", `builder-${i}`),
471
471
  branchName: `overstory/builder-${i}/test-${i}`,
472
- beadId: `test-${i}`,
472
+ taskId: `test-${i}`,
473
473
  tmuxSession: `overstory-testproject-builder-${i}`,
474
474
  state: "working",
475
475
  pid: null,
@@ -489,7 +489,7 @@ describe("checkConsistency", () => {
489
489
  capability: "reviewer",
490
490
  worktreePath: join(overstoryDir, "worktrees", "reviewer-1"),
491
491
  branchName: "overstory/reviewer-1/test-r1",
492
- beadId: "test-r1",
492
+ taskId: "test-r1",
493
493
  tmuxSession: "overstory-testproject-reviewer-1",
494
494
  state: "working",
495
495
  pid: null,
@@ -523,7 +523,7 @@ describe("checkConsistency", () => {
523
523
  capability: "builder",
524
524
  worktreePath: join(overstoryDir, "worktrees", `builder-${i}`),
525
525
  branchName: `overstory/builder-${i}/test-${i}`,
526
- beadId: `test-${i}`,
526
+ taskId: `test-${i}`,
527
527
  tmuxSession: `overstory-testproject-builder-${i}`,
528
528
  state: "working",
529
529
  pid: null,
@@ -542,7 +542,7 @@ describe("checkConsistency", () => {
542
542
  capability: "reviewer",
543
543
  worktreePath: join(overstoryDir, "worktrees", `reviewer-${i}`),
544
544
  branchName: `overstory/reviewer-${i}/test-r${i}`,
545
- beadId: `test-r${i}`,
545
+ taskId: `test-r${i}`,
546
546
  tmuxSession: `overstory-testproject-reviewer-${i}`,
547
547
  state: "working",
548
548
  pid: null,
@@ -585,7 +585,7 @@ describe("checkConsistency", () => {
585
585
  capability: "builder",
586
586
  worktreePath: join(overstoryDir, "worktrees", "builder-1"),
587
587
  branchName: "overstory/builder-1/test-1",
588
- beadId: "test-1",
588
+ taskId: "test-1",
589
589
  tmuxSession: "overstory-testproject-builder-1",
590
590
  state: "working",
591
591
  pid: null,
@@ -604,7 +604,7 @@ describe("checkConsistency", () => {
604
604
  capability: "reviewer",
605
605
  worktreePath: join(overstoryDir, "worktrees", "reviewer-1"),
606
606
  branchName: "overstory/reviewer-1/test-r1",
607
- beadId: "test-r1",
607
+ taskId: "test-r1",
608
608
  tmuxSession: "overstory-testproject-reviewer-1",
609
609
  state: "working",
610
610
  pid: null,
@@ -624,7 +624,7 @@ describe("checkConsistency", () => {
624
624
  capability: "builder",
625
625
  worktreePath: join(overstoryDir, "worktrees", "builder-2"),
626
626
  branchName: "overstory/builder-2/test-2",
627
- beadId: "test-2",
627
+ taskId: "test-2",
628
628
  tmuxSession: "overstory-testproject-builder-2",
629
629
  state: "working",
630
630
  pid: null,
@@ -51,13 +51,15 @@ describe("checkDatabases", () => {
51
51
  test("fails when database files do not exist", () => {
52
52
  const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
53
53
 
54
- expect(checks).toHaveLength(3);
54
+ expect(checks).toHaveLength(4);
55
55
  expect(checks[0]?.status).toBe("fail");
56
56
  expect(checks[0]?.name).toBe("mail.db exists");
57
57
  expect(checks[1]?.status).toBe("fail");
58
58
  expect(checks[1]?.name).toBe("metrics.db exists");
59
59
  expect(checks[2]?.status).toBe("fail");
60
60
  expect(checks[2]?.name).toBe("sessions.db exists");
61
+ expect(checks[3]?.status).toBe("fail");
62
+ expect(checks[3]?.name).toBe("merge-queue.db exists");
61
63
  });
62
64
 
63
65
  test("passes when databases exist with correct schema", () => {
@@ -141,13 +143,31 @@ describe("checkDatabases", () => {
141
143
  `);
142
144
  sessionsDb.close();
143
145
 
146
+ // Create merge-queue.db
147
+ const mergeDb = new Database(join(tempDir, "merge-queue.db"));
148
+ mergeDb.exec("PRAGMA journal_mode=WAL");
149
+ mergeDb.exec(`
150
+ CREATE TABLE merge_queue (
151
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
152
+ branch_name TEXT NOT NULL,
153
+ task_id TEXT NOT NULL,
154
+ agent_name TEXT NOT NULL,
155
+ files_modified TEXT NOT NULL DEFAULT '[]',
156
+ enqueued_at TEXT NOT NULL,
157
+ status TEXT NOT NULL DEFAULT 'pending',
158
+ resolved_tier TEXT
159
+ )
160
+ `);
161
+ mergeDb.close();
162
+
144
163
  const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
145
164
 
146
- expect(checks).toHaveLength(3);
165
+ expect(checks).toHaveLength(4);
147
166
  expect(checks.every((c) => c?.status === "pass")).toBe(true);
148
167
  expect(checks[0]?.name).toBe("mail.db health");
149
168
  expect(checks[1]?.name).toBe("metrics.db health");
150
169
  expect(checks[2]?.name).toBe("sessions.db health");
170
+ expect(checks[3]?.name).toBe("merge-queue.db health");
151
171
  });
152
172
 
153
173
  test("fails when table is missing", () => {
@@ -85,6 +85,22 @@ export const checkDatabases: DoctorCheckFn = (_config, overstoryDir): DoctorChec
85
85
  ],
86
86
  },
87
87
  },
88
+ {
89
+ name: "merge-queue.db",
90
+ tables: ["merge_queue"],
91
+ requiredColumns: {
92
+ merge_queue: [
93
+ "id",
94
+ "branch_name",
95
+ "task_id",
96
+ "agent_name",
97
+ "files_modified",
98
+ "enqueued_at",
99
+ "status",
100
+ "resolved_tier",
101
+ ],
102
+ },
103
+ },
88
104
  ];
89
105
 
90
106
  for (const dbSpec of databases) {
@@ -57,7 +57,7 @@ describe("checkDependencies", () => {
57
57
  const checks = await checkDependencies(mockConfig, "/tmp/.overstory");
58
58
 
59
59
  expect(checks).toBeArray();
60
- expect(checks.length).toBeGreaterThanOrEqual(5);
60
+ expect(checks.length).toBeGreaterThanOrEqual(7);
61
61
 
62
62
  // Verify we have checks for each required tool
63
63
  const toolNames = checks.map((c) => c.name);
@@ -66,6 +66,8 @@ describe("checkDependencies", () => {
66
66
  expect(toolNames).toContain("tmux availability");
67
67
  expect(toolNames).toContain("sd availability");
68
68
  expect(toolNames).toContain("mulch availability");
69
+ expect(toolNames).toContain("overstory availability");
70
+ expect(toolNames).toContain("cn availability");
69
71
  });
70
72
 
71
73
  test("includes bd CGO support check when bd is available", async () => {
@@ -181,4 +183,56 @@ describe("checkDependencies", () => {
181
183
  const cgoCheck = checks.find((c) => c.name === "bd CGO support");
182
184
  expect(cgoCheck).toBeUndefined();
183
185
  });
186
+
187
+ test("cn check is warn (not fail) when missing", async () => {
188
+ const checks = await checkDependencies(mockConfig, "/tmp/.overstory");
189
+ const cnCheck = checks.find((c) => c.name === "cn availability");
190
+ expect(cnCheck).toBeDefined();
191
+ // cn is optional — should never be "fail", only "pass" or "warn"
192
+ expect(cnCheck?.status).not.toBe("fail");
193
+ });
194
+
195
+ test("checks short aliases for available tools", async () => {
196
+ const checks = await checkDependencies(mockConfig, "/tmp/.overstory");
197
+ const mulchCheck = checks.find((c) => c.name === "mulch availability");
198
+ if (mulchCheck?.status === "pass") {
199
+ const mlAlias = checks.find((c) => c.name === "ml alias");
200
+ expect(mlAlias).toBeDefined();
201
+ expect(mlAlias?.category).toBe("dependencies");
202
+ expect(["pass", "warn"]).toContain(mlAlias?.status ?? "");
203
+ }
204
+ const ovCheck = checks.find((c) => c.name === "overstory availability");
205
+ if (ovCheck?.status === "pass") {
206
+ const ovAlias = checks.find((c) => c.name === "ov alias");
207
+ expect(ovAlias).toBeDefined();
208
+ expect(["pass", "warn"]).toContain(ovAlias?.status ?? "");
209
+ }
210
+ });
211
+
212
+ test("alias checks are only run when primary tool passes", async () => {
213
+ const checks = await checkDependencies(mockConfig, "/tmp/.overstory");
214
+ // If mulch failed, ml alias should NOT be present
215
+ const mulchCheck = checks.find((c) => c.name === "mulch availability");
216
+ const mlAlias = checks.find((c) => c.name === "ml alias");
217
+ if (mulchCheck?.status !== "pass") {
218
+ expect(mlAlias).toBeUndefined();
219
+ }
220
+ });
221
+
222
+ test("install hints appear in details for missing tools", async () => {
223
+ const checks = await checkDependencies(mockConfig, "/tmp/.overstory");
224
+ // Check any failing/warning check with an installHint has npm install guidance
225
+ const cnCheck = checks.find((c) => c.name === "cn availability");
226
+ if (cnCheck?.status === "warn" || cnCheck?.status === "fail") {
227
+ const hasInstallHint = cnCheck.details?.some((d) => d.includes("npm install -g"));
228
+ expect(hasInstallHint).toBe(true);
229
+ }
230
+ });
231
+
232
+ test("includes overstory availability check", async () => {
233
+ const checks = await checkDependencies(mockConfig, "/tmp/.overstory");
234
+ const ovCheck = checks.find((c) => c.name === "overstory availability");
235
+ expect(ovCheck).toBeDefined();
236
+ expect(ovCheck?.category).toBe("dependencies");
237
+ });
184
238
  });