@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.
- package/README.md +21 -9
- package/agents/builder.md +6 -0
- package/agents/coordinator.md +2 -2
- package/agents/lead.md +4 -1
- package/agents/merger.md +3 -2
- package/agents/monitor.md +1 -1
- package/agents/reviewer.md +1 -0
- package/agents/scout.md +1 -0
- package/package.json +2 -2
- package/src/agents/hooks-deployer.test.ts +6 -5
- package/src/agents/identity.test.ts +3 -2
- package/src/agents/manifest.test.ts +4 -3
- package/src/agents/overlay.test.ts +3 -2
- package/src/commands/agents.test.ts +5 -4
- package/src/commands/agents.ts +18 -8
- package/src/commands/completions.test.ts +8 -5
- package/src/commands/completions.ts +37 -1
- package/src/commands/costs.test.ts +4 -3
- package/src/commands/dashboard.test.ts +265 -6
- package/src/commands/dashboard.ts +367 -64
- package/src/commands/doctor.test.ts +3 -2
- package/src/commands/errors.test.ts +3 -2
- package/src/commands/feed.test.ts +3 -2
- package/src/commands/feed.ts +2 -29
- package/src/commands/inspect.test.ts +3 -2
- package/src/commands/log.test.ts +248 -8
- package/src/commands/log.ts +193 -110
- package/src/commands/logs.test.ts +3 -2
- package/src/commands/mail.test.ts +3 -2
- package/src/commands/metrics.test.ts +4 -3
- package/src/commands/nudge.test.ts +3 -2
- package/src/commands/prime.test.ts +3 -2
- package/src/commands/prime.ts +1 -16
- package/src/commands/replay.test.ts +3 -2
- package/src/commands/run.test.ts +2 -1
- package/src/commands/sling.test.ts +127 -0
- package/src/commands/sling.ts +101 -3
- package/src/commands/status.test.ts +8 -8
- package/src/commands/trace.test.ts +3 -2
- package/src/commands/watch.test.ts +3 -2
- package/src/config.test.ts +3 -3
- package/src/doctor/agents.test.ts +3 -2
- package/src/doctor/logs.test.ts +3 -2
- package/src/doctor/structure.test.ts +3 -2
- package/src/index.ts +3 -1
- package/src/logging/color.ts +1 -1
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +42 -1
- package/src/logging/logger.test.ts +3 -2
- package/src/mail/client.test.ts +3 -2
- package/src/mail/store.test.ts +3 -2
- package/src/merge/queue.test.ts +3 -2
- package/src/merge/resolver.test.ts +39 -0
- package/src/merge/resolver.ts +1 -1
- package/src/metrics/pricing.ts +80 -0
- package/src/metrics/transcript.test.ts +58 -1
- package/src/metrics/transcript.ts +9 -68
- package/src/mulch/client.test.ts +63 -2
- package/src/mulch/client.ts +62 -1
- package/src/runtimes/claude.test.ts +4 -3
- package/src/runtimes/pi-guards.test.ts +55 -2
- package/src/runtimes/pi-guards.ts +26 -9
- package/src/schema-consistency.test.ts +4 -2
- package/src/sessions/compat.test.ts +3 -2
- package/src/sessions/store.test.ts +3 -2
- package/src/test-helpers.ts +20 -1
- package/src/tracker/beads.test.ts +454 -0
- package/src/tracker/seeds.test.ts +461 -0
- package/src/watchdog/daemon.test.ts +4 -3
- 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
|
-
* -
|
|
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 (
|
|
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 *
|
|
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
|
|
337
|
-
`\t *
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
28
|
+
await cleanupTempDir(tempDir);
|
|
28
29
|
});
|
|
29
30
|
|
|
30
31
|
/** Helper to create an AgentSession with optional overrides. */
|
package/src/test-helpers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|