@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Beads (bd) CLI client.
3
+ *
4
+ * Wraps the `bd` command-line tool for issue tracking operations.
5
+ * All commands use `--json` for parseable output where supported.
6
+ * Uses Bun.spawn — zero runtime dependencies.
7
+ */
8
+
9
+ import { AgentError } from "../errors.ts";
10
+
11
+ /**
12
+ * A beads issue as returned by the bd CLI.
13
+ * Defined locally since it comes from an external CLI tool.
14
+ */
15
+ export interface BeadIssue {
16
+ id: string;
17
+ title: string;
18
+ status: string;
19
+ priority: number;
20
+ type: string;
21
+ assignee?: string;
22
+ description?: string;
23
+ blocks?: string[];
24
+ blockedBy?: string[];
25
+ }
26
+
27
+ export interface BeadsClient {
28
+ /** List issues that are ready for work (open, unblocked). */
29
+ ready(options?: { mol?: string }): Promise<BeadIssue[]>;
30
+
31
+ /** Show details for a specific issue. */
32
+ show(id: string): Promise<BeadIssue>;
33
+
34
+ /** Create a new issue. Returns the new issue ID. */
35
+ create(
36
+ title: string,
37
+ options?: { type?: string; priority?: number; description?: string },
38
+ ): Promise<string>;
39
+
40
+ /** Claim an issue (mark as in_progress). */
41
+ claim(id: string): Promise<void>;
42
+
43
+ /** Close an issue with an optional reason. */
44
+ close(id: string, reason?: string): Promise<void>;
45
+
46
+ /** List issues with optional filters. */
47
+ list(options?: { status?: string; limit?: number }): Promise<BeadIssue[]>;
48
+ }
49
+
50
+ /**
51
+ * Run a shell command and capture its output.
52
+ */
53
+ async function runCommand(
54
+ cmd: string[],
55
+ cwd: string,
56
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
57
+ const proc = Bun.spawn(cmd, {
58
+ cwd,
59
+ stdout: "pipe",
60
+ stderr: "pipe",
61
+ });
62
+ const stdout = await new Response(proc.stdout).text();
63
+ const stderr = await new Response(proc.stderr).text();
64
+ const exitCode = await proc.exited;
65
+ return { stdout, stderr, exitCode };
66
+ }
67
+
68
+ /**
69
+ * Parse JSON output from a bd command.
70
+ * Handles the case where output may be empty or malformed.
71
+ */
72
+ function parseJsonOutput<T>(stdout: string, context: string): T {
73
+ const trimmed = stdout.trim();
74
+ if (trimmed === "") {
75
+ throw new AgentError(`Empty output from bd ${context}`);
76
+ }
77
+ try {
78
+ return JSON.parse(trimmed) as T;
79
+ } catch {
80
+ throw new AgentError(
81
+ `Failed to parse JSON output from bd ${context}: ${trimmed.slice(0, 200)}`,
82
+ );
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Raw issue shape from the bd CLI.
88
+ * bd uses `issue_type` instead of `type`.
89
+ */
90
+ interface RawBeadIssue {
91
+ id: string;
92
+ title: string;
93
+ status: string;
94
+ priority: number;
95
+ issue_type?: string;
96
+ type?: string;
97
+ assignee?: string;
98
+ description?: string;
99
+ blocks?: string[];
100
+ blockedBy?: string[];
101
+ }
102
+
103
+ /**
104
+ * Normalize a raw bd issue into a BeadIssue.
105
+ * Maps `issue_type` -> `type` to match the BeadIssue interface.
106
+ */
107
+ function normalizeIssue(raw: RawBeadIssue): BeadIssue {
108
+ return {
109
+ id: raw.id,
110
+ title: raw.title,
111
+ status: raw.status,
112
+ priority: raw.priority,
113
+ type: raw.issue_type ?? raw.type ?? "unknown",
114
+ assignee: raw.assignee,
115
+ description: raw.description,
116
+ blocks: raw.blocks,
117
+ blockedBy: raw.blockedBy,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Create a BeadsClient bound to the given working directory.
123
+ *
124
+ * @param cwd - Working directory where bd commands should run
125
+ * @returns A BeadsClient instance wrapping the bd CLI
126
+ */
127
+ export function createBeadsClient(cwd: string): BeadsClient {
128
+ async function runBd(
129
+ args: string[],
130
+ context: string,
131
+ ): Promise<{ stdout: string; stderr: string }> {
132
+ const { stdout, stderr, exitCode } = await runCommand(["bd", ...args], cwd);
133
+ if (exitCode !== 0) {
134
+ throw new AgentError(`bd ${context} failed (exit ${exitCode}): ${stderr.trim()}`);
135
+ }
136
+ return { stdout, stderr };
137
+ }
138
+
139
+ return {
140
+ async ready(options) {
141
+ const args = ["ready", "--json"];
142
+ if (options?.mol) {
143
+ args.push("--mol", options.mol);
144
+ }
145
+ const { stdout } = await runBd(args, "ready");
146
+ const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "ready");
147
+ return raw.map(normalizeIssue);
148
+ },
149
+
150
+ async show(id) {
151
+ const { stdout } = await runBd(["show", id, "--json"], `show ${id}`);
152
+ // bd show --json returns an array with a single element
153
+ const raw = parseJsonOutput<RawBeadIssue[]>(stdout, `show ${id}`);
154
+ const first = raw[0];
155
+ if (!first) {
156
+ throw new AgentError(`bd show ${id} returned empty array`);
157
+ }
158
+ return normalizeIssue(first);
159
+ },
160
+
161
+ async create(title, options) {
162
+ const args = ["create", title, "--json"];
163
+ if (options?.type) {
164
+ args.push("--type", options.type);
165
+ }
166
+ if (options?.priority !== undefined) {
167
+ args.push("--priority", String(options.priority));
168
+ }
169
+ if (options?.description) {
170
+ args.push("--description", options.description);
171
+ }
172
+ const { stdout } = await runBd(args, "create");
173
+ const result = parseJsonOutput<{ id: string }>(stdout, "create");
174
+ return result.id;
175
+ },
176
+
177
+ async claim(id) {
178
+ await runBd(["update", id, "--status", "in_progress"], `claim ${id}`);
179
+ },
180
+
181
+ async close(id, reason) {
182
+ const args = ["close", id];
183
+ if (reason) {
184
+ args.push("--reason", reason);
185
+ }
186
+ await runBd(args, `close ${id}`);
187
+ },
188
+
189
+ async list(options) {
190
+ const args = ["list", "--json"];
191
+ if (options?.status) {
192
+ args.push("--status", options.status);
193
+ }
194
+ if (options?.limit !== undefined) {
195
+ args.push("--limit", String(options.limit));
196
+ }
197
+ const { stdout } = await runBd(args, "list");
198
+ const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "list");
199
+ return raw.map(normalizeIssue);
200
+ },
201
+ };
202
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Unit tests for beads molecules module.
3
+ *
4
+ * WHY MOCKING IS USED HERE:
5
+ * The molecules.ts module expects a bd mol API that doesn't exist yet in beads.
6
+ * Expected API: bd mol create --name, bd mol step add, bd mol list, bd mol status
7
+ * Actual API: bd formula, bd cook, bd mol pour, bd mol wisp
8
+ *
9
+ * These tests mock Bun.spawn to verify the module's logic is correct.
10
+ * When the bd API is implemented to match the module's expectations,
11
+ * these can be converted to integration tests using the real bd CLI.
12
+ *
13
+ * See mulch record mx-56558b for why mocking is normally avoided.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
17
+ import { AgentError } from "../errors.ts";
18
+ import {
19
+ createMoleculePrototype,
20
+ getConvoyStatus,
21
+ listPrototypes,
22
+ pourMolecule,
23
+ } from "./molecules.ts";
24
+
25
+ /**
26
+ * Mock Bun.spawn to simulate bd mol CLI responses.
27
+ * Returns a mock process with configurable stdout/stderr/exitCode.
28
+ */
29
+ function mockSpawn(
30
+ stdout: string,
31
+ stderr = "",
32
+ exitCode = 0,
33
+ ): {
34
+ stdout: ReadableStream<Uint8Array>;
35
+ stderr: ReadableStream<Uint8Array>;
36
+ exited: Promise<number>;
37
+ } {
38
+ const stdoutBody = new Response(stdout).body;
39
+ const stderrBody = new Response(stderr).body;
40
+ if (!stdoutBody || !stderrBody) {
41
+ throw new Error("Response body is null");
42
+ }
43
+ return {
44
+ stdout: stdoutBody,
45
+ stderr: stderrBody,
46
+ exited: Promise.resolve(exitCode),
47
+ };
48
+ }
49
+
50
+ let originalSpawn: typeof Bun.spawn;
51
+
52
+ beforeEach(() => {
53
+ // Save original spawn
54
+ originalSpawn = Bun.spawn;
55
+ });
56
+
57
+ describe("molecules", () => {
58
+ beforeEach(() => {
59
+ // Restore original spawn before each test
60
+ Bun.spawn = originalSpawn;
61
+ });
62
+
63
+ afterEach(() => {
64
+ // Ensure cleanup after each test to prevent mock leaks
65
+ Bun.spawn = originalSpawn;
66
+ });
67
+
68
+ describe("createMoleculePrototype", () => {
69
+ test("creates a prototype with ordered steps", async () => {
70
+ let callCount = 0;
71
+ // @ts-expect-error - Mocking Bun.spawn for testing
72
+ Bun.spawn = mock((cmd: string[], _opts?: unknown) => {
73
+ callCount++;
74
+ // First call: bd mol create
75
+ if (callCount === 1) {
76
+ expect(cmd).toEqual(["bd", "mol", "create", "--name", "Test Workflow", "--json"]);
77
+ return mockSpawn(JSON.stringify({ id: "mol-123" }));
78
+ }
79
+ // Subsequent calls: bd mol step add
80
+ expect(cmd[0]).toBe("bd");
81
+ expect(cmd[1]).toBe("mol");
82
+ expect(cmd[2]).toBe("step");
83
+ expect(cmd[3]).toBe("add");
84
+ expect(cmd[4]).toBe("mol-123");
85
+ return mockSpawn(JSON.stringify({ success: true }));
86
+ });
87
+
88
+ const molId = await createMoleculePrototype("/test/dir", {
89
+ name: "Test Workflow",
90
+ steps: [
91
+ { title: "Step 1: Setup", type: "task" },
92
+ { title: "Step 2: Implementation", type: "task" },
93
+ { title: "Step 3: Testing", type: "task" },
94
+ ],
95
+ });
96
+
97
+ expect(molId).toBe("mol-123");
98
+ expect(callCount).toBe(4); // 1 create + 3 step adds
99
+ });
100
+
101
+ test("creates a prototype with default type (task)", async () => {
102
+ let callCount = 0;
103
+ // @ts-expect-error - Mocking Bun.spawn for testing
104
+ Bun.spawn = mock((cmd: string[], _opts?: unknown) => {
105
+ callCount++;
106
+ if (callCount === 1) {
107
+ return mockSpawn(JSON.stringify({ id: "mol-456" }));
108
+ }
109
+ // Check that default type is "task"
110
+ expect(cmd).toContain("--type");
111
+ const typeIndex = cmd.indexOf("--type");
112
+ expect(cmd[typeIndex + 1]).toBe("task");
113
+ return mockSpawn(JSON.stringify({ success: true }));
114
+ });
115
+
116
+ const molId = await createMoleculePrototype("/test/dir", {
117
+ name: "Default Type Workflow",
118
+ steps: [{ title: "Step without explicit type" }],
119
+ });
120
+
121
+ expect(molId).toBe("mol-456");
122
+ expect(callCount).toBe(2); // 1 create + 1 step add
123
+ });
124
+
125
+ test("creates a prototype with empty steps array", async () => {
126
+ // @ts-expect-error - Mocking Bun.spawn for testing
127
+ Bun.spawn = mock(() => {
128
+ return mockSpawn(JSON.stringify({ id: "mol-empty" }));
129
+ });
130
+
131
+ const molId = await createMoleculePrototype("/test/dir", {
132
+ name: "Empty Workflow",
133
+ steps: [],
134
+ });
135
+
136
+ expect(molId).toBe("mol-empty");
137
+ });
138
+
139
+ test("throws AgentError on create failure", async () => {
140
+ // @ts-expect-error - Mocking Bun.spawn for testing
141
+ Bun.spawn = mock(() => {
142
+ return mockSpawn("", "bd mol create failed: invalid name", 1);
143
+ });
144
+
145
+ await expect(
146
+ createMoleculePrototype("/test/dir", {
147
+ name: "Bad",
148
+ steps: [],
149
+ }),
150
+ ).rejects.toThrow(AgentError);
151
+ });
152
+
153
+ test("throws AgentError on step add failure", async () => {
154
+ let callCount = 0;
155
+ // @ts-expect-error - Mocking Bun.spawn for testing
156
+ Bun.spawn = mock(() => {
157
+ callCount++;
158
+ if (callCount === 1) {
159
+ return mockSpawn(JSON.stringify({ id: "mol-789" }));
160
+ }
161
+ // Step add fails
162
+ return mockSpawn("", "step add failed", 1);
163
+ });
164
+
165
+ await expect(
166
+ createMoleculePrototype("/test/dir", {
167
+ name: "Test",
168
+ steps: [{ title: "Step 1" }],
169
+ }),
170
+ ).rejects.toThrow(AgentError);
171
+ });
172
+ });
173
+
174
+ describe("listPrototypes", () => {
175
+ test("returns all created prototypes", async () => {
176
+ // @ts-expect-error - Mocking Bun.spawn for testing
177
+ Bun.spawn = mock(() => {
178
+ return mockSpawn(
179
+ JSON.stringify([
180
+ { id: "mol-1", name: "List Test 1", stepCount: 1 },
181
+ { id: "mol-2", name: "List Test 2", stepCount: 2 },
182
+ ]),
183
+ );
184
+ });
185
+
186
+ const prototypes = await listPrototypes("/test/dir");
187
+
188
+ expect(prototypes).toHaveLength(2);
189
+ expect(prototypes[0]).toEqual({ id: "mol-1", name: "List Test 1", stepCount: 1 });
190
+ expect(prototypes[1]).toEqual({ id: "mol-2", name: "List Test 2", stepCount: 2 });
191
+ });
192
+
193
+ test("returns empty array when no prototypes exist", async () => {
194
+ // @ts-expect-error - Mocking Bun.spawn for testing
195
+ Bun.spawn = mock(() => {
196
+ return mockSpawn(JSON.stringify([]));
197
+ });
198
+
199
+ const prototypes = await listPrototypes("/test/dir");
200
+
201
+ expect(Array.isArray(prototypes)).toBe(true);
202
+ expect(prototypes).toHaveLength(0);
203
+ });
204
+
205
+ test("throws AgentError on failure", async () => {
206
+ // @ts-expect-error - Mocking Bun.spawn for testing
207
+ Bun.spawn = mock(() => {
208
+ return mockSpawn("", "bd mol list failed", 1);
209
+ });
210
+
211
+ await expect(listPrototypes("/test/dir")).rejects.toThrow(AgentError);
212
+ });
213
+
214
+ test("throws AgentError on empty output", async () => {
215
+ // @ts-expect-error - Mocking Bun.spawn for testing
216
+ Bun.spawn = mock(() => {
217
+ return mockSpawn("");
218
+ });
219
+
220
+ await expect(listPrototypes("/test/dir")).rejects.toThrow(AgentError);
221
+ });
222
+ });
223
+
224
+ describe("pourMolecule", () => {
225
+ test("pours a prototype into actual issues", async () => {
226
+ // @ts-expect-error - Mocking Bun.spawn for testing
227
+ Bun.spawn = mock((cmd: string[]) => {
228
+ expect(cmd).toEqual(["bd", "mol", "pour", "mol-123", "--json"]);
229
+ return mockSpawn(JSON.stringify({ ids: ["issue-1", "issue-2", "issue-3"] }));
230
+ });
231
+
232
+ const issueIds = await pourMolecule("/test/dir", {
233
+ prototypeId: "mol-123",
234
+ });
235
+
236
+ expect(Array.isArray(issueIds)).toBe(true);
237
+ expect(issueIds).toHaveLength(3);
238
+ expect(issueIds).toEqual(["issue-1", "issue-2", "issue-3"]);
239
+ });
240
+
241
+ test("applies prefix when provided", async () => {
242
+ // @ts-expect-error - Mocking Bun.spawn for testing
243
+ Bun.spawn = mock((cmd: string[]) => {
244
+ expect(cmd).toEqual(["bd", "mol", "pour", "mol-456", "--json", "--prefix", "v2.0"]);
245
+ return mockSpawn(JSON.stringify({ ids: ["issue-4", "issue-5"] }));
246
+ });
247
+
248
+ const issueIds = await pourMolecule("/test/dir", {
249
+ prototypeId: "mol-456",
250
+ prefix: "v2.0",
251
+ });
252
+
253
+ expect(issueIds).toEqual(["issue-4", "issue-5"]);
254
+ });
255
+
256
+ test("handles empty prototype (0 steps)", async () => {
257
+ // @ts-expect-error - Mocking Bun.spawn for testing
258
+ Bun.spawn = mock(() => {
259
+ return mockSpawn(JSON.stringify({ ids: [] }));
260
+ });
261
+
262
+ const issueIds = await pourMolecule("/test/dir", {
263
+ prototypeId: "mol-empty",
264
+ });
265
+
266
+ expect(Array.isArray(issueIds)).toBe(true);
267
+ expect(issueIds).toHaveLength(0);
268
+ });
269
+
270
+ test("throws AgentError for nonexistent prototype", async () => {
271
+ // @ts-expect-error - Mocking Bun.spawn for testing
272
+ Bun.spawn = mock(() => {
273
+ return mockSpawn("", "prototype not found", 1);
274
+ });
275
+
276
+ await expect(
277
+ pourMolecule("/test/dir", {
278
+ prototypeId: "nonexistent-mol-id",
279
+ }),
280
+ ).rejects.toThrow(AgentError);
281
+ });
282
+ });
283
+
284
+ describe("getConvoyStatus", () => {
285
+ test("returns status for poured prototype", async () => {
286
+ // @ts-expect-error - Mocking Bun.spawn for testing
287
+ Bun.spawn = mock((cmd: string[]) => {
288
+ expect(cmd).toEqual(["bd", "mol", "status", "mol-123", "--json"]);
289
+ return mockSpawn(
290
+ JSON.stringify({
291
+ total: 3,
292
+ completed: 1,
293
+ inProgress: 1,
294
+ blocked: 0,
295
+ }),
296
+ );
297
+ });
298
+
299
+ const status = await getConvoyStatus("/test/dir", "mol-123");
300
+
301
+ expect(status).toBeDefined();
302
+ expect(status.total).toBe(3);
303
+ expect(status.completed).toBe(1);
304
+ expect(status.inProgress).toBe(1);
305
+ expect(status.blocked).toBe(0);
306
+ });
307
+
308
+ test("handles empty poured prototype", async () => {
309
+ // @ts-expect-error - Mocking Bun.spawn for testing
310
+ Bun.spawn = mock(() => {
311
+ return mockSpawn(
312
+ JSON.stringify({
313
+ total: 0,
314
+ completed: 0,
315
+ inProgress: 0,
316
+ blocked: 0,
317
+ }),
318
+ );
319
+ });
320
+
321
+ const status = await getConvoyStatus("/test/dir", "mol-empty");
322
+
323
+ expect(status.total).toBe(0);
324
+ expect(status.completed).toBe(0);
325
+ expect(status.inProgress).toBe(0);
326
+ expect(status.blocked).toBe(0);
327
+ });
328
+
329
+ test("throws AgentError for nonexistent prototype", async () => {
330
+ // @ts-expect-error - Mocking Bun.spawn for testing
331
+ Bun.spawn = mock(() => {
332
+ return mockSpawn("", "prototype not found", 1);
333
+ });
334
+
335
+ await expect(getConvoyStatus("/test/dir", "nonexistent-mol-id")).rejects.toThrow(AgentError);
336
+ });
337
+ });
338
+ });