@os-eco/overstory-cli 0.6.1 → 0.6.4

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 (80) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -4
  3. package/src/agents/hooks-deployer.test.ts +94 -16
  4. package/src/agents/hooks-deployer.ts +18 -0
  5. package/src/agents/manifest.test.ts +86 -0
  6. package/src/commands/agents.test.ts +3 -3
  7. package/src/commands/agents.ts +59 -88
  8. package/src/commands/clean.test.ts +31 -46
  9. package/src/commands/clean.ts +28 -49
  10. package/src/commands/completions.ts +14 -0
  11. package/src/commands/coordinator.test.ts +131 -24
  12. package/src/commands/coordinator.ts +100 -63
  13. package/src/commands/costs.test.ts +2 -2
  14. package/src/commands/costs.ts +96 -75
  15. package/src/commands/dashboard.test.ts +2 -2
  16. package/src/commands/dashboard.ts +73 -93
  17. package/src/commands/doctor.test.ts +2 -2
  18. package/src/commands/doctor.ts +92 -79
  19. package/src/commands/errors.test.ts +2 -2
  20. package/src/commands/errors.ts +56 -50
  21. package/src/commands/feed.test.ts +2 -2
  22. package/src/commands/feed.ts +86 -83
  23. package/src/commands/group.ts +167 -177
  24. package/src/commands/hooks.test.ts +2 -2
  25. package/src/commands/hooks.ts +52 -42
  26. package/src/commands/init.test.ts +19 -19
  27. package/src/commands/init.ts +7 -16
  28. package/src/commands/inspect.test.ts +2 -2
  29. package/src/commands/inspect.ts +54 -57
  30. package/src/commands/log.test.ts +5 -10
  31. package/src/commands/log.ts +90 -84
  32. package/src/commands/logs.test.ts +1 -1
  33. package/src/commands/logs.ts +101 -104
  34. package/src/commands/mail.ts +157 -169
  35. package/src/commands/merge.test.ts +20 -58
  36. package/src/commands/merge.ts +13 -43
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +33 -34
  39. package/src/commands/monitor.test.ts +3 -3
  40. package/src/commands/monitor.ts +56 -61
  41. package/src/commands/nudge.ts +41 -89
  42. package/src/commands/prime.test.ts +15 -47
  43. package/src/commands/prime.ts +7 -44
  44. package/src/commands/replay.test.ts +2 -2
  45. package/src/commands/replay.ts +79 -86
  46. package/src/commands/run.ts +97 -77
  47. package/src/commands/sling.test.ts +196 -0
  48. package/src/commands/sling.ts +24 -54
  49. package/src/commands/spec.test.ts +13 -39
  50. package/src/commands/spec.ts +30 -99
  51. package/src/commands/status.ts +46 -42
  52. package/src/commands/stop.test.ts +21 -39
  53. package/src/commands/stop.ts +18 -33
  54. package/src/commands/supervisor.test.ts +3 -5
  55. package/src/commands/supervisor.ts +136 -157
  56. package/src/commands/trace.test.ts +9 -9
  57. package/src/commands/trace.ts +54 -77
  58. package/src/commands/watch.test.ts +2 -2
  59. package/src/commands/watch.ts +38 -45
  60. package/src/commands/worktree.test.ts +8 -8
  61. package/src/commands/worktree.ts +63 -46
  62. package/src/config.test.ts +96 -0
  63. package/src/doctor/databases.test.ts +22 -2
  64. package/src/doctor/databases.ts +16 -0
  65. package/src/doctor/dependencies.test.ts +55 -1
  66. package/src/doctor/dependencies.ts +113 -18
  67. package/src/e2e/init-sling-lifecycle.test.ts +6 -6
  68. package/src/index.ts +223 -213
  69. package/src/logging/color.test.ts +74 -91
  70. package/src/logging/color.ts +52 -46
  71. package/src/logging/reporter.test.ts +10 -10
  72. package/src/logging/reporter.ts +6 -5
  73. package/src/merge/queue.test.ts +66 -0
  74. package/src/merge/queue.ts +15 -0
  75. package/src/schema-consistency.test.ts +239 -0
  76. package/src/sessions/compat.ts +1 -1
  77. package/src/sessions/store.test.ts +37 -0
  78. package/src/sessions/store.ts +11 -0
  79. package/src/worktree/tmux.test.ts +98 -9
  80. package/src/worktree/tmux.ts +18 -0
@@ -5,138 +5,121 @@ import { dirname, join } from "node:path";
5
5
  const projectRoot = join(dirname(import.meta.dir), "..");
6
6
 
7
7
  describe("color module", () => {
8
- // Test via subprocess to control env vars at import time
8
+ test("color.red is a function that wraps text", async () => {
9
+ const { color } = await import("./color.ts");
10
+ const result = color.red("hello");
11
+ expect(result).toContain("hello");
12
+ expect(typeof result).toBe("string");
13
+ });
9
14
 
10
- test("colors enabled by default (no env vars)", async () => {
11
- const proc = Bun.spawn(
12
- [
13
- "bun",
14
- "-e",
15
- 'import { color, colorsEnabled } from "./src/logging/color.ts"; console.log(JSON.stringify({ colorsEnabled, reset: color.reset }))',
16
- ],
17
- {
18
- cwd: projectRoot,
19
- stdout: "pipe",
20
- stderr: "pipe",
21
- env: { ...process.env, NO_COLOR: undefined, FORCE_COLOR: undefined, TERM: undefined },
22
- },
23
- );
24
- await proc.exited;
25
- const output = await new Response(proc.stdout).text();
26
- const result = JSON.parse(output.trim());
27
- expect(result.colorsEnabled).toBe(true);
28
- expect(result.reset).toBe("\x1b[0m");
15
+ test("color functions all exist", async () => {
16
+ const { color } = await import("./color.ts");
17
+ const expectedKeys = [
18
+ "bold",
19
+ "dim",
20
+ "red",
21
+ "green",
22
+ "yellow",
23
+ "blue",
24
+ "magenta",
25
+ "cyan",
26
+ "white",
27
+ "gray",
28
+ ];
29
+ for (const key of expectedKeys) {
30
+ expect(key in color).toBe(true);
31
+ expect(typeof (color as Record<string, unknown>)[key]).toBe("function");
32
+ }
29
33
  });
30
34
 
31
- test("NO_COLOR disables colors", async () => {
32
- const proc = Bun.spawn(
33
- [
34
- "bun",
35
- "-e",
36
- 'import { color, colorsEnabled } from "./src/logging/color.ts"; console.log(JSON.stringify({ colorsEnabled, reset: color.reset }))',
37
- ],
38
- {
39
- cwd: projectRoot,
40
- stdout: "pipe",
41
- stderr: "pipe",
42
- env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: undefined },
43
- },
44
- );
45
- await proc.exited;
46
- const output = await new Response(proc.stdout).text();
47
- const result = JSON.parse(output.trim());
48
- expect(result.colorsEnabled).toBe(false);
49
- expect(result.reset).toBe("");
35
+ test("brand palette functions wrap text", async () => {
36
+ const { brand, accent, muted } = await import("./color.ts");
37
+ const result = brand("Overstory");
38
+ expect(result).toContain("Overstory");
39
+ expect(typeof accent("test")).toBe("string");
40
+ expect(typeof muted("test")).toBe("string");
50
41
  });
51
42
 
52
- test("TERM=dumb disables colors", async () => {
53
- const proc = Bun.spawn(
54
- [
55
- "bun",
56
- "-e",
57
- 'import { color, colorsEnabled } from "./src/logging/color.ts"; console.log(JSON.stringify({ colorsEnabled, reset: color.reset }))',
58
- ],
59
- {
60
- cwd: projectRoot,
61
- stdout: "pipe",
62
- stderr: "pipe",
63
- env: { ...process.env, TERM: "dumb", NO_COLOR: undefined, FORCE_COLOR: undefined },
64
- },
65
- );
66
- await proc.exited;
67
- const output = await new Response(proc.stdout).text();
68
- const result = JSON.parse(output.trim());
69
- expect(result.colorsEnabled).toBe(false);
70
- expect(result.reset).toBe("");
43
+ test("noColor is an identity function", async () => {
44
+ const { noColor } = await import("./color.ts");
45
+ expect(noColor("hello")).toBe("hello");
46
+ expect(noColor("")).toBe("");
71
47
  });
72
48
 
73
- test("FORCE_COLOR overrides NO_COLOR", async () => {
49
+ test("stripAnsi removes escape codes", async () => {
50
+ const { stripAnsi } = await import("./color.ts");
51
+ expect(stripAnsi("\x1b[31mhello\x1b[39m")).toBe("hello");
52
+ expect(stripAnsi("plain")).toBe("plain");
53
+ expect(stripAnsi("\x1b[1m\x1b[31mbold red\x1b[39m\x1b[22m")).toBe("bold red");
54
+ });
55
+
56
+ test("visibleLength excludes ANSI codes", async () => {
57
+ const { visibleLength } = await import("./color.ts");
58
+ expect(visibleLength("\x1b[31mhello\x1b[39m")).toBe(5);
59
+ expect(visibleLength("hello")).toBe(5);
60
+ expect(visibleLength("")).toBe(0);
61
+ });
62
+
63
+ test("setQuiet/isQuiet controls quiet mode", async () => {
64
+ const { isQuiet, setQuiet } = await import("./color.ts");
65
+ expect(isQuiet()).toBe(false);
66
+ setQuiet(true);
67
+ expect(isQuiet()).toBe(true);
68
+ setQuiet(false);
69
+ expect(isQuiet()).toBe(false);
70
+ });
71
+
72
+ test("NO_COLOR env causes chalk.level to be 0", async () => {
74
73
  const proc = Bun.spawn(
75
74
  [
76
75
  "bun",
77
76
  "-e",
78
- 'import { color, colorsEnabled } from "./src/logging/color.ts"; console.log(JSON.stringify({ colorsEnabled, reset: color.reset }))',
77
+ 'import chalk from "chalk"; console.log(JSON.stringify({ level: chalk.level }))',
79
78
  ],
80
79
  {
81
80
  cwd: projectRoot,
82
81
  stdout: "pipe",
83
82
  stderr: "pipe",
84
- env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "1" },
83
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: undefined },
85
84
  },
86
85
  );
87
86
  await proc.exited;
88
87
  const output = await new Response(proc.stdout).text();
89
88
  const result = JSON.parse(output.trim());
90
- expect(result.colorsEnabled).toBe(true);
91
- expect(result.reset).toBe("\x1b[0m");
89
+ expect(result.level).toBe(0);
92
90
  });
93
91
 
94
- test("FORCE_COLOR=0 disables colors", async () => {
92
+ test("FORCE_COLOR overrides NO_COLOR", async () => {
95
93
  const proc = Bun.spawn(
96
94
  [
97
95
  "bun",
98
96
  "-e",
99
- 'import { color, colorsEnabled } from "./src/logging/color.ts"; console.log(JSON.stringify({ colorsEnabled, reset: color.reset }))',
97
+ 'import chalk from "chalk"; console.log(JSON.stringify({ level: chalk.level }))',
100
98
  ],
101
99
  {
102
100
  cwd: projectRoot,
103
101
  stdout: "pipe",
104
102
  stderr: "pipe",
105
- env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: undefined },
103
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "1" },
106
104
  },
107
105
  );
108
106
  await proc.exited;
109
107
  const output = await new Response(proc.stdout).text();
110
108
  const result = JSON.parse(output.trim());
111
- expect(result.colorsEnabled).toBe(false);
109
+ expect(result.level).toBeGreaterThan(0);
112
110
  });
113
111
 
114
- test("setQuiet/isQuiet controls quiet mode", async () => {
115
- const { isQuiet, setQuiet } = await import("./color.ts");
116
- expect(isQuiet()).toBe(false);
117
- setQuiet(true);
118
- expect(isQuiet()).toBe(true);
119
- setQuiet(false);
120
- expect(isQuiet()).toBe(false);
112
+ test("chalk re-export is available", async () => {
113
+ const { chalk } = await import("./color.ts");
114
+ expect(typeof chalk.red).toBe("function");
115
+ expect(chalk.red("test")).toContain("test");
121
116
  });
122
117
 
123
- test("all color keys present", async () => {
118
+ test("ColorFn type: color functions accept strings and return strings", async () => {
124
119
  const { color } = await import("./color.ts");
125
- const expectedKeys = [
126
- "reset",
127
- "bold",
128
- "dim",
129
- "red",
130
- "green",
131
- "yellow",
132
- "blue",
133
- "magenta",
134
- "cyan",
135
- "white",
136
- "gray",
137
- ];
138
- for (const key of expectedKeys) {
139
- expect(key in color).toBe(true);
140
- }
120
+ // Each color function should accept a string and return a string
121
+ const result = color.bold(color.red("nested"));
122
+ expect(result).toContain("nested");
123
+ expect(typeof result).toBe("string");
141
124
  });
142
125
  });
@@ -1,63 +1,69 @@
1
1
  /**
2
- * Central ANSI color and output control.
2
+ * Central color and output control using Chalk.
3
3
  *
4
- * Respects the NO_COLOR convention (https://no-color.org/):
5
- * - When NO_COLOR env var is set (any value), all color codes become empty strings
6
- * - When TERM=dumb, colors are disabled
7
- * - When FORCE_COLOR is set to a truthy value, colors are forced on
8
- *
9
- * Also provides --quiet support: when quiet mode is enabled, non-error
10
- * output is suppressed. Commands check isQuiet() before writing to stdout.
4
+ * Chalk natively handles NO_COLOR, FORCE_COLOR, and TERM=dumb.
5
+ * See https://github.com/chalk/chalk#supportscolor for detection logic.
11
6
  */
12
7
 
13
- /**
14
- * Priority order for color detection:
15
- * 1. FORCE_COLOR (highest) — set to non-"0" to force colors on
16
- * 2. NO_COLOR — any value disables colors
17
- * 3. TERM=dumb — disables colors
18
- * 4. Default: colors enabled
19
- */
20
- function shouldUseColor(): boolean {
21
- if (process.env.FORCE_COLOR !== undefined) {
22
- return process.env.FORCE_COLOR !== "0";
23
- }
24
- if (process.env.NO_COLOR !== undefined) {
25
- return false;
26
- }
27
- if (process.env.TERM === "dumb") {
28
- return false;
29
- }
30
- return true;
31
- }
8
+ import chalk from "chalk";
32
9
 
33
- const useColor = shouldUseColor();
10
+ // --- Brand palette (os-eco brand colors) ---
34
11
 
35
- function code(ansiCode: string): string {
36
- return useColor ? ansiCode : "";
37
- }
12
+ /** Forest green Overstory primary brand color. */
13
+ export const brand = chalk.rgb(27, 94, 32);
14
+
15
+ /** Amber — highlights, warnings. */
16
+ export const accent = chalk.rgb(255, 183, 77);
17
+
18
+ /** Stone gray — secondary text, muted content. */
19
+ export const muted = chalk.rgb(120, 120, 110);
20
+
21
+ // --- Standard color functions ---
38
22
 
39
23
  /**
40
- * ANSI color codes that respect NO_COLOR.
41
- * When colors are disabled, all values are empty strings.
24
+ * Color functions that wrap text with ANSI codes.
25
+ * Each value is a function: color.red("text") returns "\x1b[31mtext\x1b[39m".
26
+ * Chalk auto-resets when wrapping, so color.reset is not needed.
42
27
  */
43
28
  export const color = {
44
- reset: code("\x1b[0m"),
45
- bold: code("\x1b[1m"),
46
- dim: code("\x1b[2m"),
47
- red: code("\x1b[31m"),
48
- green: code("\x1b[32m"),
49
- yellow: code("\x1b[33m"),
50
- blue: code("\x1b[34m"),
51
- magenta: code("\x1b[35m"),
52
- cyan: code("\x1b[36m"),
53
- white: code("\x1b[37m"),
54
- gray: code("\x1b[90m"),
29
+ bold: chalk.bold,
30
+ dim: chalk.dim,
31
+ red: chalk.red,
32
+ green: chalk.green,
33
+ yellow: chalk.yellow,
34
+ blue: chalk.blue,
35
+ magenta: chalk.magenta,
36
+ cyan: chalk.cyan,
37
+ white: chalk.white,
38
+ gray: chalk.gray,
55
39
  } as const;
56
40
 
57
- /** Whether ANSI colors are currently enabled. */
58
- export const colorsEnabled = useColor;
41
+ // Re-export chalk for direct use (chaining, custom RGB, etc.)
42
+ export { chalk };
43
+
44
+ /** Type for color function values (for consumers that store colors in variables). */
45
+ export type ColorFn = (text: string) => string;
46
+
47
+ /** Identity function for "no color" cases (replaces old color.white as default). */
48
+ export const noColor: ColorFn = (text: string) => text;
49
+
50
+ // --- ANSI strip utilities (for visible-width calculations in dashboard) ---
51
+
52
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC (0x1B) is required to match ANSI escape sequences
53
+ const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
54
+
55
+ /** Strip ANSI escape codes from a string. */
56
+ export function stripAnsi(str: string): string {
57
+ return str.replace(ANSI_REGEX, "");
58
+ }
59
+
60
+ /** Visible string length (excluding ANSI escape codes). */
61
+ export function visibleLength(str: string): number {
62
+ return stripAnsi(str).length;
63
+ }
59
64
 
60
65
  // --- Quiet mode ---
66
+
61
67
  let quietMode = false;
62
68
 
63
69
  /** Enable quiet mode (suppress non-error output). */
@@ -106,12 +106,12 @@ describe("formatLogLine", () => {
106
106
  expect(result).toContain("[invalid-timestamp]");
107
107
  });
108
108
 
109
- test("contains ANSI escape codes in output", () => {
109
+ test("output is a formatted string with level label and timestamp", () => {
110
110
  const result = formatLogLine(makeEvent());
111
- // \x1b[ is the ANSI escape sequence prefix
112
- expect(result).toContain("\x1b[");
113
- // Reset sequence should appear at least once
114
- expect(result).toContain("\x1b[0m");
111
+ // Chalk adds ANSI codes in TTY environments; in non-TTY (CI/tests) output is plain.
112
+ // Verify the result contains the expected label and timestamp regardless of color mode.
113
+ expect(result).toContain("INF");
114
+ expect(result).toContain("[14:30:00]");
115
115
  });
116
116
 
117
117
  test("uses different ANSI color codes for different levels", () => {
@@ -120,11 +120,11 @@ describe("formatLogLine", () => {
120
120
  const warnResult = formatLogLine(makeEvent({ level: "warn" }));
121
121
  const errorResult = formatLogLine(makeEvent({ level: "error" }));
122
122
 
123
- // Each level uses a distinct color: gray(90), blue(34), yellow(33), red(31)
124
- expect(debugResult).toContain("\x1b[90m");
125
- expect(infoResult).toContain("\x1b[34m");
126
- expect(warnResult).toContain("\x1b[33m");
127
- expect(errorResult).toContain("\x1b[31m");
123
+ // Each level produces distinct output (Chalk's exact escape codes may vary by terminal)
124
+ expect(debugResult).not.toBe(infoResult);
125
+ expect(infoResult).not.toBe(warnResult);
126
+ expect(warnResult).not.toBe(errorResult);
127
+ expect(debugResult).not.toBe(errorResult);
128
128
  });
129
129
 
130
130
  test("formats boolean data values via String()", () => {
@@ -2,13 +2,14 @@
2
2
  * Console reporter with ANSI colors for human-readable log output.
3
3
  *
4
4
  * Formats LogEvent objects into colored terminal output.
5
- * Uses ANSI escape codes directly (no external dependencies).
5
+ * Uses Chalk for color formatting.
6
6
  */
7
7
 
8
8
  import type { LogEvent } from "../types.ts";
9
+ import type { ColorFn } from "./color.ts";
9
10
  import { color, isQuiet } from "./color.ts";
10
11
 
11
- const LEVEL_COLORS: Record<LogEvent["level"], string> = {
12
+ const LEVEL_COLORS: Record<LogEvent["level"], ColorFn> = {
12
13
  debug: color.gray,
13
14
  info: color.blue,
14
15
  warn: color.yellow,
@@ -35,13 +36,13 @@ export function formatLogLine(event: LogEvent): string {
35
36
  const time = extractTime(event.timestamp);
36
37
 
37
38
  // Build the agent prefix
38
- const agentPart = event.agentName ? `${color.dim}${event.agentName}${color.reset} | ` : "";
39
+ const agentPart = event.agentName ? `${color.dim(event.agentName)} | ` : "";
39
40
 
40
41
  // Build key=value pairs from data
41
42
  const dataPart = formatData(event.data);
42
- const dataSuffix = dataPart.length > 0 ? ` ${color.dim}${dataPart}${color.reset}` : "";
43
+ const dataSuffix = dataPart.length > 0 ? ` ${color.dim(dataPart)}` : "";
43
44
 
44
- return `${color.dim}[${time}]${color.reset} ${levelColor}${color.bold}${label}${color.reset} ${agentPart}${event.event}${dataSuffix}`;
45
+ return `${color.dim(`[${time}]`)} ${levelColor(color.bold(label))} ${agentPart}${event.event}${dataSuffix}`;
45
46
  }
46
47
 
47
48
  /**
@@ -1,3 +1,4 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
3
  import { mkdtemp, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
@@ -333,6 +334,71 @@ describe("createMergeQueue", () => {
333
334
  });
334
335
  });
335
336
 
337
+ describe("bead_id → task_id migration", () => {
338
+ test("renames bead_id column to task_id on existing databases", () => {
339
+ // Create a database with the old bead_id schema
340
+ const db = new Database(queuePath);
341
+ db.exec("PRAGMA journal_mode = WAL");
342
+ db.exec(`
343
+ CREATE TABLE merge_queue (
344
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
345
+ branch_name TEXT NOT NULL,
346
+ bead_id TEXT NOT NULL,
347
+ agent_name TEXT NOT NULL,
348
+ files_modified TEXT NOT NULL DEFAULT '[]',
349
+ enqueued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now')),
350
+ status TEXT NOT NULL DEFAULT 'pending'
351
+ CHECK(status IN ('pending','merging','merged','conflict','failed')),
352
+ resolved_tier TEXT
353
+ CHECK(resolved_tier IS NULL OR resolved_tier IN ('clean-merge','auto-resolve','ai-resolve','reimagine'))
354
+ )
355
+ `);
356
+ db.exec(
357
+ "INSERT INTO merge_queue (branch_name, bead_id, agent_name, files_modified, status, enqueued_at) VALUES ('overstory/test/bead-1', 'bead-1', 'test', '[\"src/a.ts\"]', 'pending', '2026-01-01T00:00:00.000')",
358
+ );
359
+ db.close();
360
+
361
+ // Opening with createMergeQueue should migrate and work
362
+ const queue = createMergeQueue(queuePath);
363
+ const entries = queue.list();
364
+
365
+ expect(entries).toHaveLength(1);
366
+ expect(entries[0]?.beadId).toBe("bead-1");
367
+ expect(entries[0]?.branchName).toBe("overstory/test/bead-1");
368
+
369
+ // New inserts should also work
370
+ const newEntry = queue.enqueue({
371
+ branchName: "overstory/test/bead-2",
372
+ beadId: "bead-2",
373
+ agentName: "test",
374
+ filesModified: ["src/b.ts"],
375
+ });
376
+ expect(newEntry.beadId).toBe("bead-2");
377
+
378
+ queue.close();
379
+ });
380
+
381
+ test("no-op when task_id column already exists", () => {
382
+ // Create with current schema (has task_id)
383
+ const queue1 = createMergeQueue(queuePath);
384
+ queue1.enqueue({
385
+ branchName: "overstory/test/bead-1",
386
+ beadId: "bead-1",
387
+ agentName: "test",
388
+ filesModified: [],
389
+ });
390
+ queue1.close();
391
+
392
+ // Re-opening should not error
393
+ const queue2 = createMergeQueue(queuePath);
394
+ const entries = queue2.list();
395
+
396
+ expect(entries).toHaveLength(1);
397
+ expect(entries[0]?.beadId).toBe("bead-1");
398
+ queue2.close();
399
+ });
400
+ });
401
+
336
402
  describe("close", () => {
337
403
  test("closes the database connection", () => {
338
404
  const queue = createMergeQueue(queuePath);
@@ -83,6 +83,18 @@ function rowToEntry(row: MergeQueueRow): MergeEntry {
83
83
  };
84
84
  }
85
85
 
86
+ /**
87
+ * Migrate an existing merge_queue table from bead_id to task_id column.
88
+ * Safe to call multiple times — only renames if bead_id exists and task_id does not.
89
+ */
90
+ function migrateBeadIdToTaskId(db: Database): void {
91
+ const rows = db.prepare("PRAGMA table_info(merge_queue)").all() as Array<{ name: string }>;
92
+ const existingColumns = new Set(rows.map((r) => r.name));
93
+ if (existingColumns.has("bead_id") && !existingColumns.has("task_id")) {
94
+ db.exec("ALTER TABLE merge_queue RENAME COLUMN bead_id TO task_id");
95
+ }
96
+ }
97
+
86
98
  /**
87
99
  * Create a new MergeQueue backed by a SQLite database at the given path.
88
100
  *
@@ -101,6 +113,9 @@ export function createMergeQueue(dbPath: string): MergeQueue {
101
113
  db.exec(CREATE_TABLE);
102
114
  db.exec(CREATE_INDEXES);
103
115
 
116
+ // Migrate: rename bead_id → task_id on existing tables
117
+ migrateBeadIdToTaskId(db);
118
+
104
119
  // Prepare statements for frequent operations
105
120
  const insertStmt = db.prepare<
106
121
  MergeQueueRow,