@os-eco/overstory-cli 0.7.2 → 0.7.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 (70) hide show
  1. package/README.md +21 -9
  2. package/agents/builder.md +6 -0
  3. package/agents/coordinator.md +2 -2
  4. package/agents/lead.md +4 -1
  5. package/agents/merger.md +3 -2
  6. package/agents/monitor.md +1 -1
  7. package/agents/reviewer.md +1 -0
  8. package/agents/scout.md +1 -0
  9. package/package.json +2 -2
  10. package/src/agents/hooks-deployer.test.ts +6 -5
  11. package/src/agents/identity.test.ts +3 -2
  12. package/src/agents/manifest.test.ts +4 -3
  13. package/src/agents/overlay.test.ts +3 -2
  14. package/src/commands/agents.test.ts +5 -4
  15. package/src/commands/agents.ts +18 -8
  16. package/src/commands/completions.test.ts +8 -5
  17. package/src/commands/completions.ts +37 -1
  18. package/src/commands/costs.test.ts +4 -3
  19. package/src/commands/dashboard.test.ts +265 -6
  20. package/src/commands/dashboard.ts +367 -64
  21. package/src/commands/doctor.test.ts +3 -2
  22. package/src/commands/errors.test.ts +3 -2
  23. package/src/commands/feed.test.ts +3 -2
  24. package/src/commands/feed.ts +2 -29
  25. package/src/commands/inspect.test.ts +3 -2
  26. package/src/commands/log.test.ts +248 -8
  27. package/src/commands/log.ts +193 -110
  28. package/src/commands/logs.test.ts +3 -2
  29. package/src/commands/mail.test.ts +3 -2
  30. package/src/commands/metrics.test.ts +4 -3
  31. package/src/commands/nudge.test.ts +3 -2
  32. package/src/commands/prime.test.ts +3 -2
  33. package/src/commands/prime.ts +1 -16
  34. package/src/commands/replay.test.ts +3 -2
  35. package/src/commands/run.test.ts +2 -1
  36. package/src/commands/sling.test.ts +127 -0
  37. package/src/commands/sling.ts +101 -3
  38. package/src/commands/status.test.ts +8 -8
  39. package/src/commands/trace.test.ts +3 -2
  40. package/src/commands/watch.test.ts +3 -2
  41. package/src/config.test.ts +3 -3
  42. package/src/doctor/agents.test.ts +3 -2
  43. package/src/doctor/logs.test.ts +3 -2
  44. package/src/doctor/structure.test.ts +3 -2
  45. package/src/index.ts +3 -1
  46. package/src/logging/color.ts +1 -1
  47. package/src/logging/format.test.ts +110 -0
  48. package/src/logging/format.ts +42 -1
  49. package/src/logging/logger.test.ts +3 -2
  50. package/src/mail/client.test.ts +3 -2
  51. package/src/mail/store.test.ts +3 -2
  52. package/src/merge/queue.test.ts +3 -2
  53. package/src/merge/resolver.test.ts +39 -0
  54. package/src/merge/resolver.ts +1 -1
  55. package/src/metrics/pricing.ts +80 -0
  56. package/src/metrics/transcript.test.ts +58 -1
  57. package/src/metrics/transcript.ts +9 -68
  58. package/src/mulch/client.test.ts +63 -2
  59. package/src/mulch/client.ts +62 -1
  60. package/src/runtimes/claude.test.ts +4 -3
  61. package/src/runtimes/pi-guards.test.ts +55 -2
  62. package/src/runtimes/pi-guards.ts +26 -9
  63. package/src/schema-consistency.test.ts +4 -2
  64. package/src/sessions/compat.test.ts +3 -2
  65. package/src/sessions/store.test.ts +3 -2
  66. package/src/test-helpers.ts +20 -1
  67. package/src/tracker/beads.test.ts +454 -0
  68. package/src/tracker/seeds.test.ts +461 -0
  69. package/src/watchdog/daemon.test.ts +4 -3
  70. package/src/watchdog/triage.test.ts +3 -2
@@ -8,7 +8,7 @@
8
8
  // to prevent tool execution — equivalent to Claude Code's PreToolUse hooks.
9
9
  //
10
10
  // Activity tracking fires via pi.exec("ov log ...") on tool_call,
11
- // tool_execution_end, and session_shutdown events so the SessionStore
11
+ // tool_execution_end, agent_end, and session_shutdown events so the SessionStore
12
12
  // lastActivity stays fresh and the watchdog does not zombie-classify agents.
13
13
 
14
14
  import {
@@ -113,7 +113,11 @@ function toRegExpArrayLiteral(patterns: string[]): string {
113
113
  * Activity tracking:
114
114
  * - tool_call handler: fire-and-forget "ov log tool-start" to update lastActivity.
115
115
  * - tool_execution_end handler: fire-and-forget "ov log tool-end".
116
- * - session_shutdown handler: awaited "ov log session-end" to mark agent completed.
116
+ * - agent_end handler: awaited "ov log session-end" fires when the agentic loop
117
+ * completes (task done). Without this, completed Pi agents get watchdog-escalated
118
+ * through stalled → nudge → triage → terminate.
119
+ * - session_shutdown handler: awaited "ov log session-end" — fires on Ctrl+C/SIGTERM.
120
+ * Kept as a safety net in case agent_end does not fire (e.g., crash, force-kill).
117
121
  *
118
122
  * These tracking calls prevent the watchdog from zombie-classifying Pi agents due
119
123
  * to stale lastActivity timestamps (the root cause of the zombie state bug).
@@ -190,7 +194,7 @@ export function generatePiGuardExtension(hooks: HooksDef): string {
190
194
  `//`,
191
195
  `// Uses Pi's ExtensionAPI factory style: export default function(pi: ExtensionAPI) { ... }`,
192
196
  `// pi.on("tool_call", ...) returns { block: true, reason } to prevent tool execution.`,
193
- `// pi.exec("ov", [...]) calls the overstory CLI for activity tracking.`,
197
+ `// pi.exec("ov", [...]) calls the overstory CLI for activity tracking and lifecycle.`,
194
198
  `import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";`,
195
199
  ``,
196
200
  `const AGENT_NAME = "${agentName}";`,
@@ -241,7 +245,7 @@ export function generatePiGuardExtension(hooks: HooksDef): string {
241
245
  `\tpi.on("tool_call", async (event) => {`,
242
246
  `\t\t// Activity tracking: update lastActivity so watchdog knows agent is alive.`,
243
247
  `\t\t// Fire-and-forget — do not await (avoids latency on every tool call).`,
244
- `\t\tpi.exec("ov", ["log", "tool-start", "--agent", AGENT_NAME]).catch(() => {});`,
248
+ `\t\tpi.exec("ov", ["log", "tool-start", "--agent", AGENT_NAME, "--tool-name", event.toolName]).catch(() => {});`,
245
249
  ``,
246
250
  `\t\t// 1. Block native team/task tools (all agents).`,
247
251
  `\t\tif (TEAM_BLOCKED.has(event.toolName)) {`,
@@ -326,15 +330,28 @@ export function generatePiGuardExtension(hooks: HooksDef): string {
326
330
  `\t * Tool execution end: fire-and-forget "ov log tool-end" for event tracking.`,
327
331
  `\t * Paired with tool_call's tool-start fire for proper begin/end event logging.`,
328
332
  `\t */`,
329
- `\tpi.on("tool_execution_end", async (_event) => {`,
330
- `\t\tpi.exec("ov", ["log", "tool-end", "--agent", AGENT_NAME]).catch(() => {});`,
333
+ `\tpi.on("tool_execution_end", async (event) => {`,
334
+ `\t\tpi.exec("ov", ["log", "tool-end", "--agent", AGENT_NAME, "--tool-name", event.toolName]).catch(() => {});`,
331
335
  `\t});`,
332
336
  ``,
333
337
  `\t/**`,
334
- `\t * Session shutdown: log session-end so the agent transitions to "completed" state.`,
338
+ `\t * Agent end: log session-end when the agentic loop completes (task done).`,
335
339
  `\t *`,
336
- `\t * Awaited so it completes before Pi exits. Without this call, the agent stays in`,
337
- `\t * "booting" or "working" state forever, requiring manual cleanup or watchdog termination.`,
340
+ `\t * Awaited so it completes before Pi moves on. Without this handler, completed`,
341
+ `\t * Pi agents never transition to "completed" state in the SessionStore, causing`,
342
+ `\t * the watchdog to escalate them through stalled → nudge → triage → terminate.`,
343
+ `\t *`,
344
+ `\t * Fires when the agent finishes its work — before session_shutdown.`,
345
+ `\t */`,
346
+ `\tpi.on("agent_end", async (_event) => {`,
347
+ `\t\tawait pi.exec("ov", ["log", "session-end", "--agent", AGENT_NAME]).catch(() => {});`,
348
+ `\t});`,
349
+ ``,
350
+ `\t/**`,
351
+ `\t * Session shutdown: safety-net session-end log for non-graceful exits.`,
352
+ `\t *`,
353
+ `\t * Awaited so it completes before Pi exits. Kept as a fallback in case`,
354
+ `\t * agent_end does not fire (e.g., crash, force-kill, Ctrl+C before task completes).`,
338
355
  `\t *`,
339
356
  `\t * Fires on Ctrl+C, Ctrl+D, or SIGTERM.`,
340
357
  `\t */`,
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { Database } from "bun:sqlite";
14
14
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
- import { mkdtemp, rm } from "node:fs/promises";
15
+ import { mkdtemp } from "node:fs/promises";
16
16
  import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { createEventStore } from "./events/store.ts";
@@ -21,6 +21,8 @@ import { createMergeQueue } from "./merge/queue.ts";
21
21
  import { createMetricsStore } from "./metrics/store.ts";
22
22
  import { createSessionStore } from "./sessions/store.ts";
23
23
 
24
+ import { cleanupTempDir } from "./test-helpers.ts";
25
+
24
26
  /** Extract sorted column names from a table via PRAGMA table_info(). */
25
27
  function getTableColumns(db: Database, tableName: string): string[] {
26
28
  const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
@@ -35,7 +37,7 @@ describe("SQL schema consistency", () => {
35
37
  });
36
38
 
37
39
  afterEach(async () => {
38
- await rm(tmpDir, { recursive: true, force: true });
40
+ await cleanupTempDir(tmpDir);
39
41
  });
40
42
 
41
43
  describe("SessionStore", () => {
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
9
+ import { mkdtemp, writeFile } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
12
13
  import { openSessionStore } from "./compat.ts";
13
14
 
14
15
  let tempDir: string;
@@ -22,7 +23,7 @@ beforeEach(async () => {
22
23
  });
23
24
 
24
25
  afterEach(async () => {
25
- await rm(tempDir, { recursive: true, force: true });
26
+ await cleanupTempDir(tempDir);
26
27
  });
27
28
 
28
29
  /** Create a sessions.json with the given entries. */
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { mkdtemp, rm } from "node:fs/promises";
9
+ import { mkdtemp } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
12
13
  import type { AgentSession, AgentState, InsertRun, Run, RunStore } from "../types.ts";
13
14
  import { createRunStore, createSessionStore, type SessionStore } from "./store.ts";
14
15
 
@@ -24,7 +25,7 @@ beforeEach(async () => {
24
25
 
25
26
  afterEach(async () => {
26
27
  store.close();
27
- await rm(tempDir, { recursive: true, force: true });
28
+ await cleanupTempDir(tempDir);
28
29
  });
29
30
 
30
31
  /** Helper to create an AgentSession with optional overrides. */
@@ -95,9 +95,28 @@ export async function getDefaultBranch(repoDir: string): Promise<string> {
95
95
 
96
96
  /**
97
97
  * Remove a temp directory. Safe to call even if the directory doesn't exist.
98
+ *
99
+ * On Windows, SQLite WAL/SHM file handles may linger briefly after db.close(),
100
+ * causing EBUSY errors on immediate rm(). Retries with exponential backoff
101
+ * (up to ~1.5s total) to handle this OS-level timing issue.
98
102
  */
99
103
  export async function cleanupTempDir(dir: string): Promise<void> {
100
- await rm(dir, { recursive: true, force: true });
104
+ const maxRetries = process.platform === "win32" ? 5 : 0;
105
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
106
+ try {
107
+ await rm(dir, { recursive: true, force: true });
108
+ return;
109
+ } catch (err: unknown) {
110
+ const code = (err as NodeJS.ErrnoException).code;
111
+ if (code === "EBUSY" && attempt < maxRetries) {
112
+ // Exponential backoff: 50, 100, 200, 400, 800ms
113
+ await Bun.sleep(50 * 2 ** attempt);
114
+ continue;
115
+ }
116
+ // Non-EBUSY or final attempt: swallow (temp dirs are cleaned by OS anyway)
117
+ if (code !== "ENOENT") return;
118
+ }
119
+ }
101
120
  }
102
121
 
103
122
  /**
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Beads tracker adapter tests.
3
+ *
4
+ * Uses Bun.spawn mocks — legitimate exception to "never mock what you can use for real".
5
+ * The `bd` CLI may not be installed in all environments and would modify real tracker
6
+ * state (creating/closing actual beads) if invoked directly in tests.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
10
+ import { AgentError } from "../errors.ts";
11
+ import { createBeadsTracker } from "./beads.ts";
12
+
13
+ /**
14
+ * Helper to create a mock Bun.spawn return value.
15
+ *
16
+ * The actual code reads stdout/stderr via `new Response(proc.stdout).text()`
17
+ * and `new Response(proc.stderr).text()`, so we need ReadableStreams.
18
+ */
19
+ function mockSpawnResult(
20
+ stdout: string,
21
+ stderr: string,
22
+ exitCode: number,
23
+ ): {
24
+ stdout: ReadableStream<Uint8Array>;
25
+ stderr: ReadableStream<Uint8Array>;
26
+ exited: Promise<number>;
27
+ pid: number;
28
+ } {
29
+ return {
30
+ stdout: new Response(stdout).body as ReadableStream<Uint8Array>,
31
+ stderr: new Response(stderr).body as ReadableStream<Uint8Array>,
32
+ exited: Promise.resolve(exitCode),
33
+ pid: 12345,
34
+ };
35
+ }
36
+
37
+ const TEST_CWD = "/test/repo";
38
+
39
+ describe("createBeadsTracker — ready()", () => {
40
+ let spawnSpy: ReturnType<typeof spyOn>;
41
+
42
+ beforeEach(() => {
43
+ spawnSpy = spyOn(Bun, "spawn");
44
+ });
45
+
46
+ afterEach(() => {
47
+ spawnSpy.mockRestore();
48
+ });
49
+
50
+ test("returns normalized TrackerIssue[] with issue_type → type mapping", async () => {
51
+ const raw = [
52
+ {
53
+ id: "bd-1",
54
+ title: "Fix login",
55
+ status: "open",
56
+ priority: 1,
57
+ issue_type: "bug",
58
+ },
59
+ {
60
+ id: "bd-2",
61
+ title: "Add auth",
62
+ status: "open",
63
+ priority: 2,
64
+ issue_type: "feature",
65
+ assignee: "bob",
66
+ },
67
+ ];
68
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
69
+
70
+ const tracker = createBeadsTracker(TEST_CWD);
71
+ const issues = await tracker.ready();
72
+
73
+ expect(issues).toHaveLength(2);
74
+ expect(issues[0]).toMatchObject({ id: "bd-1", title: "Fix login", type: "bug" });
75
+ expect(issues[1]).toMatchObject({ id: "bd-2", type: "feature", assignee: "bob" });
76
+ });
77
+
78
+ test("verifies CLI args: [bd, ready, --json]", async () => {
79
+ spawnSpy.mockImplementation(() => mockSpawnResult("[]", "", 0));
80
+
81
+ const tracker = createBeadsTracker(TEST_CWD);
82
+ await tracker.ready();
83
+
84
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
85
+ const cmd = callArgs[0] as string[];
86
+ expect(cmd).toEqual(["bd", "ready", "--json"]);
87
+ });
88
+
89
+ test("throws AgentError on non-zero exit code", async () => {
90
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "bd: command not found", 1));
91
+
92
+ const tracker = createBeadsTracker(TEST_CWD);
93
+ await expect(tracker.ready()).rejects.toThrow(AgentError);
94
+ });
95
+ });
96
+
97
+ describe("createBeadsTracker — show()", () => {
98
+ let spawnSpy: ReturnType<typeof spyOn>;
99
+
100
+ beforeEach(() => {
101
+ spawnSpy = spyOn(Bun, "spawn");
102
+ });
103
+
104
+ afterEach(() => {
105
+ spawnSpy.mockRestore();
106
+ });
107
+
108
+ test("returns normalized TrackerIssue from bd array response", async () => {
109
+ // bd show --json returns an array with a single element
110
+ const raw = [
111
+ {
112
+ id: "bd-42",
113
+ title: "Critical bug",
114
+ status: "open",
115
+ priority: 1,
116
+ issue_type: "bug",
117
+ description: "Crashes on startup",
118
+ blocks: ["bd-50"],
119
+ },
120
+ ];
121
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
122
+
123
+ const tracker = createBeadsTracker(TEST_CWD);
124
+ const issue = await tracker.show("bd-42");
125
+
126
+ expect(issue).toMatchObject({
127
+ id: "bd-42",
128
+ title: "Critical bug",
129
+ type: "bug",
130
+ description: "Crashes on startup",
131
+ blocks: ["bd-50"],
132
+ });
133
+ });
134
+
135
+ test("throws AgentError when bd returns empty array", async () => {
136
+ spawnSpy.mockImplementation(() => mockSpawnResult("[]", "", 0));
137
+
138
+ const tracker = createBeadsTracker(TEST_CWD);
139
+ await expect(tracker.show("bd-99")).rejects.toThrow(AgentError);
140
+ });
141
+
142
+ test("verifies CLI args: [bd, show, <id>, --json]", async () => {
143
+ const raw = [{ id: "bd-1", title: "t", status: "open", priority: 1, issue_type: "task" }];
144
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
145
+
146
+ const tracker = createBeadsTracker(TEST_CWD);
147
+ await tracker.show("bd-1");
148
+
149
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
150
+ const cmd = callArgs[0] as string[];
151
+ expect(cmd).toEqual(["bd", "show", "bd-1", "--json"]);
152
+ });
153
+ });
154
+
155
+ describe("createBeadsTracker — create()", () => {
156
+ let spawnSpy: ReturnType<typeof spyOn>;
157
+
158
+ beforeEach(() => {
159
+ spawnSpy = spyOn(Bun, "spawn");
160
+ });
161
+
162
+ afterEach(() => {
163
+ spawnSpy.mockRestore();
164
+ });
165
+
166
+ test("returns new issue ID from { id: '...' } response", async () => {
167
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify({ id: "bd-101" }), "", 0));
168
+
169
+ const tracker = createBeadsTracker(TEST_CWD);
170
+ const id = await tracker.create("New feature");
171
+
172
+ expect(id).toBe("bd-101");
173
+ });
174
+
175
+ test("verifies CLI args: [bd, create, <title>, --json]", async () => {
176
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify({ id: "bd-1" }), "", 0));
177
+
178
+ const tracker = createBeadsTracker(TEST_CWD);
179
+ await tracker.create("My Issue");
180
+
181
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
182
+ const cmd = callArgs[0] as string[];
183
+ expect(cmd[0]).toBe("bd");
184
+ expect(cmd[1]).toBe("create");
185
+ expect(cmd[2]).toBe("My Issue");
186
+ expect(cmd).toContain("--json");
187
+ });
188
+
189
+ test("passes optional --type, --priority, --description args", async () => {
190
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify({ id: "bd-200" }), "", 0));
191
+
192
+ const tracker = createBeadsTracker(TEST_CWD);
193
+ await tracker.create("My task", {
194
+ type: "feature",
195
+ priority: 2,
196
+ description: "A detailed description",
197
+ });
198
+
199
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
200
+ const cmd = callArgs[0] as string[];
201
+ expect(cmd).toContain("--type");
202
+ expect(cmd).toContain("feature");
203
+ expect(cmd).toContain("--priority");
204
+ expect(cmd).toContain("2");
205
+ expect(cmd).toContain("--description");
206
+ expect(cmd).toContain("A detailed description");
207
+ });
208
+ });
209
+
210
+ describe("createBeadsTracker — claim()", () => {
211
+ let spawnSpy: ReturnType<typeof spyOn>;
212
+
213
+ beforeEach(() => {
214
+ spawnSpy = spyOn(Bun, "spawn");
215
+ });
216
+
217
+ afterEach(() => {
218
+ spawnSpy.mockRestore();
219
+ });
220
+
221
+ test("calls [bd, update, <id>, --status, in_progress]", async () => {
222
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
223
+
224
+ const tracker = createBeadsTracker(TEST_CWD);
225
+ await tracker.claim("bd-7");
226
+
227
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
228
+ const cmd = callArgs[0] as string[];
229
+ expect(cmd).toEqual(["bd", "update", "bd-7", "--status", "in_progress"]);
230
+ });
231
+ });
232
+
233
+ describe("createBeadsTracker — close()", () => {
234
+ let spawnSpy: ReturnType<typeof spyOn>;
235
+
236
+ beforeEach(() => {
237
+ spawnSpy = spyOn(Bun, "spawn");
238
+ });
239
+
240
+ afterEach(() => {
241
+ spawnSpy.mockRestore();
242
+ });
243
+
244
+ test("calls [bd, close, <id>] without reason", async () => {
245
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
246
+
247
+ const tracker = createBeadsTracker(TEST_CWD);
248
+ await tracker.close("bd-10");
249
+
250
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
251
+ const cmd = callArgs[0] as string[];
252
+ expect(cmd).toEqual(["bd", "close", "bd-10"]);
253
+ });
254
+
255
+ test("calls [bd, close, <id>, --reason, ...] with reason", async () => {
256
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
257
+
258
+ const tracker = createBeadsTracker(TEST_CWD);
259
+ await tracker.close("bd-10", "Completed implementation");
260
+
261
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
262
+ const cmd = callArgs[0] as string[];
263
+ expect(cmd).toEqual(["bd", "close", "bd-10", "--reason", "Completed implementation"]);
264
+ });
265
+ });
266
+
267
+ describe("createBeadsTracker — list()", () => {
268
+ let spawnSpy: ReturnType<typeof spyOn>;
269
+
270
+ beforeEach(() => {
271
+ spawnSpy = spyOn(Bun, "spawn");
272
+ });
273
+
274
+ afterEach(() => {
275
+ spawnSpy.mockRestore();
276
+ });
277
+
278
+ test("returns normalized issues from bd array response", async () => {
279
+ const raw = [
280
+ { id: "bd-1", title: "Task A", status: "open", priority: 1, issue_type: "task" },
281
+ {
282
+ id: "bd-2",
283
+ title: "Bug B",
284
+ status: "in_progress",
285
+ priority: 2,
286
+ issue_type: "bug",
287
+ },
288
+ ];
289
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
290
+
291
+ const tracker = createBeadsTracker(TEST_CWD);
292
+ const issues = await tracker.list();
293
+
294
+ expect(issues).toHaveLength(2);
295
+ expect(issues[0]).toMatchObject({ id: "bd-1", type: "task" });
296
+ expect(issues[1]).toMatchObject({ id: "bd-2", type: "bug", status: "in_progress" });
297
+ });
298
+
299
+ test("verifies CLI args: [bd, list, --json]", async () => {
300
+ spawnSpy.mockImplementation(() => mockSpawnResult("[]", "", 0));
301
+
302
+ const tracker = createBeadsTracker(TEST_CWD);
303
+ await tracker.list();
304
+
305
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
306
+ const cmd = callArgs[0] as string[];
307
+ expect(cmd[0]).toBe("bd");
308
+ expect(cmd[1]).toBe("list");
309
+ expect(cmd).toContain("--json");
310
+ });
311
+
312
+ test("passes --status and --limit options", async () => {
313
+ spawnSpy.mockImplementation(() => mockSpawnResult("[]", "", 0));
314
+
315
+ const tracker = createBeadsTracker(TEST_CWD);
316
+ await tracker.list({ status: "open", limit: 5 });
317
+
318
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
319
+ const cmd = callArgs[0] as string[];
320
+ expect(cmd).toContain("--status");
321
+ expect(cmd).toContain("open");
322
+ expect(cmd).toContain("--limit");
323
+ expect(cmd).toContain("5");
324
+ });
325
+ });
326
+
327
+ describe("createBeadsTracker — sync()", () => {
328
+ let spawnSpy: ReturnType<typeof spyOn>;
329
+
330
+ beforeEach(() => {
331
+ spawnSpy = spyOn(Bun, "spawn");
332
+ });
333
+
334
+ afterEach(() => {
335
+ spawnSpy.mockRestore();
336
+ });
337
+
338
+ test("calls [bd, sync] directly (not via beads client)", async () => {
339
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
340
+
341
+ const tracker = createBeadsTracker(TEST_CWD);
342
+ await tracker.sync();
343
+
344
+ // sync() calls Bun.spawn directly in beads.ts, not via the beads client
345
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
346
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
347
+ const cmd = callArgs[0] as string[];
348
+ expect(cmd).toEqual(["bd", "sync"]);
349
+ });
350
+
351
+ test("throws AgentError on failure", async () => {
352
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "bd sync failed", 1));
353
+
354
+ const tracker = createBeadsTracker(TEST_CWD);
355
+ await expect(tracker.sync()).rejects.toThrow(AgentError);
356
+ });
357
+ });
358
+
359
+ describe("createBeadsTracker — issue_type normalization", () => {
360
+ let spawnSpy: ReturnType<typeof spyOn>;
361
+
362
+ beforeEach(() => {
363
+ spawnSpy = spyOn(Bun, "spawn");
364
+ });
365
+
366
+ afterEach(() => {
367
+ spawnSpy.mockRestore();
368
+ });
369
+
370
+ test("maps issue_type to type field", async () => {
371
+ const raw = [{ id: "bd-1", title: "t", status: "open", priority: 1, issue_type: "bug" }];
372
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
373
+
374
+ const tracker = createBeadsTracker(TEST_CWD);
375
+ const issues = await tracker.ready();
376
+
377
+ expect(issues[0]?.type).toBe("bug");
378
+ });
379
+
380
+ test("falls back to type when issue_type absent", async () => {
381
+ const raw = [{ id: "bd-1", title: "t", status: "open", priority: 1, type: "feature" }];
382
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
383
+
384
+ const tracker = createBeadsTracker(TEST_CWD);
385
+ const issues = await tracker.ready();
386
+
387
+ expect(issues[0]?.type).toBe("feature");
388
+ });
389
+
390
+ test("defaults to 'unknown' when neither issue_type nor type present", async () => {
391
+ const raw = [{ id: "bd-1", title: "t", status: "open", priority: 1 }];
392
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
393
+
394
+ const tracker = createBeadsTracker(TEST_CWD);
395
+ const issues = await tracker.ready();
396
+
397
+ expect(issues[0]?.type).toBe("unknown");
398
+ });
399
+
400
+ test("prefers issue_type over type when both present", async () => {
401
+ const raw = [
402
+ {
403
+ id: "bd-1",
404
+ title: "t",
405
+ status: "open",
406
+ priority: 1,
407
+ issue_type: "bug",
408
+ type: "feature",
409
+ },
410
+ ];
411
+ spawnSpy.mockImplementation(() => mockSpawnResult(JSON.stringify(raw), "", 0));
412
+
413
+ const tracker = createBeadsTracker(TEST_CWD);
414
+ const issues = await tracker.ready();
415
+
416
+ expect(issues[0]?.type).toBe("bug");
417
+ });
418
+ });
419
+
420
+ describe("createBeadsTracker — cwd propagation", () => {
421
+ let spawnSpy: ReturnType<typeof spyOn>;
422
+
423
+ beforeEach(() => {
424
+ spawnSpy = spyOn(Bun, "spawn");
425
+ });
426
+
427
+ afterEach(() => {
428
+ spawnSpy.mockRestore();
429
+ });
430
+
431
+ test("propagates cwd to Bun.spawn for ready()", async () => {
432
+ spawnSpy.mockImplementation(() => mockSpawnResult("[]", "", 0));
433
+
434
+ const customCwd = "/my/project/root";
435
+ const tracker = createBeadsTracker(customCwd);
436
+ await tracker.ready();
437
+
438
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
439
+ const opts = callArgs[1] as { cwd: string };
440
+ expect(opts.cwd).toBe(customCwd);
441
+ });
442
+
443
+ test("propagates cwd to Bun.spawn for sync()", async () => {
444
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
445
+
446
+ const customCwd = "/my/project/root";
447
+ const tracker = createBeadsTracker(customCwd);
448
+ await tracker.sync();
449
+
450
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
451
+ const opts = callArgs[1] as { cwd: string };
452
+ expect(opts.cwd).toBe(customCwd);
453
+ });
454
+ });