@katyella/legio 0.1.0

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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,227 @@
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 node:child_process — zero runtime npm dependencies.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { AgentError } from "../errors.ts";
11
+
12
+ /**
13
+ * A beads issue as returned by the bd CLI.
14
+ * Defined locally since it comes from an external CLI tool.
15
+ */
16
+ export interface BeadIssue {
17
+ id: string;
18
+ title: string;
19
+ status: string;
20
+ priority: number;
21
+ type: string;
22
+ assignee?: string;
23
+ description?: string;
24
+ blocks?: string[];
25
+ blockedBy?: string[];
26
+ closedAt?: string;
27
+ closeReason?: string;
28
+ createdAt?: string;
29
+ }
30
+
31
+ export interface BeadsClient {
32
+ /** List issues that are ready for work (open, unblocked). */
33
+ ready(options?: { mol?: string }): Promise<BeadIssue[]>;
34
+
35
+ /** Show details for a specific issue. */
36
+ show(id: string): Promise<BeadIssue>;
37
+
38
+ /** Create a new issue. Returns the new issue ID. */
39
+ create(
40
+ title: string,
41
+ options?: { type?: string; priority?: number; description?: string },
42
+ ): Promise<string>;
43
+
44
+ /** Claim an issue (mark as in_progress). */
45
+ claim(id: string): Promise<void>;
46
+
47
+ /** Close an issue with an optional reason. */
48
+ close(id: string, reason?: string): Promise<void>;
49
+
50
+ /** List issues with optional filters. */
51
+ list(options?: { status?: string; limit?: number; all?: boolean }): Promise<BeadIssue[]>;
52
+ }
53
+
54
+ /**
55
+ * Run a shell command and capture its output.
56
+ */
57
+ async function runCommand(
58
+ cmd: string[],
59
+ cwd: string,
60
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
61
+ const [command, ...args] = cmd;
62
+ if (!command) throw new Error("Empty command");
63
+ return new Promise((resolve, reject) => {
64
+ const proc = spawn(command, args, {
65
+ cwd,
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ });
68
+ const chunks: { stdout: Buffer[]; stderr: Buffer[] } = { stdout: [], stderr: [] };
69
+ proc.stdout.on("data", (data: Buffer) => chunks.stdout.push(data));
70
+ proc.stderr.on("data", (data: Buffer) => chunks.stderr.push(data));
71
+ proc.on("error", reject);
72
+ proc.on("close", (code) => {
73
+ resolve({
74
+ stdout: Buffer.concat(chunks.stdout).toString(),
75
+ stderr: Buffer.concat(chunks.stderr).toString(),
76
+ exitCode: code ?? 1,
77
+ });
78
+ });
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Parse JSON output from a bd command.
84
+ * Handles the case where output may be empty or malformed.
85
+ */
86
+ function parseJsonOutput<T>(stdout: string, context: string): T {
87
+ const trimmed = stdout.trim();
88
+ if (trimmed === "") {
89
+ throw new AgentError(`Empty output from bd ${context}`);
90
+ }
91
+ try {
92
+ return JSON.parse(trimmed) as T;
93
+ } catch {
94
+ throw new AgentError(
95
+ `Failed to parse JSON output from bd ${context}: ${trimmed.slice(0, 200)}`,
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Raw issue shape from the bd CLI.
102
+ * bd uses `issue_type` instead of `type`.
103
+ */
104
+ interface RawBeadIssue {
105
+ id: string;
106
+ title: string;
107
+ status: string;
108
+ priority: number;
109
+ issue_type?: string;
110
+ type?: string;
111
+ owner?: string;
112
+ assignee?: string;
113
+ description?: string;
114
+ blocks?: string[];
115
+ blocked_by?: string[];
116
+ blockedBy?: string[];
117
+ closed_at?: string;
118
+ close_reason?: string;
119
+ created_at?: string;
120
+ }
121
+
122
+ /**
123
+ * Normalize a raw bd issue into a BeadIssue.
124
+ * Maps `issue_type` -> `type` to match the BeadIssue interface.
125
+ */
126
+ function normalizeIssue(raw: RawBeadIssue): BeadIssue {
127
+ return {
128
+ id: raw.id,
129
+ title: raw.title,
130
+ status: raw.status,
131
+ priority: raw.priority,
132
+ type: raw.issue_type ?? raw.type ?? "unknown",
133
+ assignee: raw.owner ?? raw.assignee,
134
+ description: raw.description,
135
+ blocks: raw.blocks,
136
+ blockedBy: raw.blocked_by ?? raw.blockedBy,
137
+ closedAt: raw.closed_at,
138
+ closeReason: raw.close_reason,
139
+ createdAt: raw.created_at,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Create a BeadsClient bound to the given working directory.
145
+ *
146
+ * @param cwd - Working directory where bd commands should run
147
+ * @returns A BeadsClient instance wrapping the bd CLI
148
+ */
149
+ export function createBeadsClient(cwd: string): BeadsClient {
150
+ async function runBd(
151
+ args: string[],
152
+ context: string,
153
+ ): Promise<{ stdout: string; stderr: string }> {
154
+ const { stdout, stderr, exitCode } = await runCommand(["bd", ...args], cwd);
155
+ if (exitCode !== 0) {
156
+ throw new AgentError(`bd ${context} failed (exit ${exitCode}): ${stderr.trim()}`);
157
+ }
158
+ return { stdout, stderr };
159
+ }
160
+
161
+ return {
162
+ async ready(options) {
163
+ const args = ["ready", "--json"];
164
+ if (options?.mol) {
165
+ args.push("--mol", options.mol);
166
+ }
167
+ const { stdout } = await runBd(args, "ready");
168
+ const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "ready");
169
+ return raw.map(normalizeIssue);
170
+ },
171
+
172
+ async show(id) {
173
+ const { stdout } = await runBd(["show", id, "--json"], `show ${id}`);
174
+ // bd show --json returns an array with a single element
175
+ const raw = parseJsonOutput<RawBeadIssue[]>(stdout, `show ${id}`);
176
+ const first = raw[0];
177
+ if (!first) {
178
+ throw new AgentError(`bd show ${id} returned empty array`);
179
+ }
180
+ return normalizeIssue(first);
181
+ },
182
+
183
+ async create(title, options) {
184
+ const args = ["create", title, "--json"];
185
+ if (options?.type) {
186
+ args.push("--type", options.type);
187
+ }
188
+ if (options?.priority !== undefined) {
189
+ args.push("--priority", String(options.priority));
190
+ }
191
+ if (options?.description) {
192
+ args.push("--description", options.description);
193
+ }
194
+ const { stdout } = await runBd(args, "create");
195
+ const result = parseJsonOutput<{ id: string }>(stdout, "create");
196
+ return result.id;
197
+ },
198
+
199
+ async claim(id) {
200
+ await runBd(["update", id, "--status", "in_progress"], `claim ${id}`);
201
+ },
202
+
203
+ async close(id, reason) {
204
+ const args = ["close", id];
205
+ if (reason) {
206
+ args.push("--reason", reason);
207
+ }
208
+ await runBd(args, `close ${id}`);
209
+ },
210
+
211
+ async list(options) {
212
+ const args = ["list", "--json"];
213
+ if (options?.status) {
214
+ args.push("--status", options.status);
215
+ }
216
+ if (options?.limit !== undefined) {
217
+ args.push("--limit", String(options.limit));
218
+ }
219
+ if (options?.all) {
220
+ args.push("--all");
221
+ }
222
+ const { stdout } = await runBd(args, "list");
223
+ const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "list");
224
+ return raw.map(normalizeIssue);
225
+ },
226
+ };
227
+ }
@@ -0,0 +1,320 @@
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 node:child_process 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 { EventEmitter } from "node:events";
17
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
18
+ import { AgentError } from "../errors.ts";
19
+ import {
20
+ createMoleculePrototype,
21
+ getConvoyStatus,
22
+ listPrototypes,
23
+ pourMolecule,
24
+ } from "./molecules.ts";
25
+
26
+ vi.mock("node:child_process");
27
+
28
+ import { spawn } from "node:child_process";
29
+
30
+ const mockSpawn = vi.mocked(spawn);
31
+
32
+ /**
33
+ * Create a mock ChildProcess-like object that emits stdout/stderr data then closes.
34
+ * Uses setImmediate so listeners are attached before events fire.
35
+ */
36
+ function makeMockProcess(stdout: string, stderr = "", exitCode = 0) {
37
+ const stdoutEE = new EventEmitter();
38
+ const stderrEE = new EventEmitter();
39
+ const proc = new EventEmitter();
40
+ // @ts-expect-error - Test mock attaches streams to EventEmitter
41
+ proc.stdout = stdoutEE;
42
+ // @ts-expect-error - Test mock attaches streams to EventEmitter
43
+ proc.stderr = stderrEE;
44
+ setImmediate(() => {
45
+ if (stdout) stdoutEE.emit("data", Buffer.from(stdout));
46
+ if (stderr) stderrEE.emit("data", Buffer.from(stderr));
47
+ proc.emit("close", exitCode);
48
+ });
49
+ return proc;
50
+ }
51
+
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.clearAllMocks();
58
+ });
59
+
60
+ describe("molecules", () => {
61
+ describe("createMoleculePrototype", () => {
62
+ test("creates a prototype with ordered steps", async () => {
63
+ let callCount = 0;
64
+ mockSpawn.mockImplementation(((command: string, args: string[]) => {
65
+ callCount++;
66
+ const cmd = [command, ...(args ?? [])];
67
+ // First call: bd mol create
68
+ if (callCount === 1) {
69
+ expect(cmd).toEqual(["bd", "mol", "create", "--name", "Test Workflow", "--json"]);
70
+ return makeMockProcess(JSON.stringify({ id: "mol-123" }));
71
+ }
72
+ // Subsequent calls: bd mol step add
73
+ expect(cmd[0]).toBe("bd");
74
+ expect(cmd[1]).toBe("mol");
75
+ expect(cmd[2]).toBe("step");
76
+ expect(cmd[3]).toBe("add");
77
+ expect(cmd[4]).toBe("mol-123");
78
+ return makeMockProcess(JSON.stringify({ success: true }));
79
+ }) as unknown as typeof spawn);
80
+
81
+ const molId = await createMoleculePrototype("/test/dir", {
82
+ name: "Test Workflow",
83
+ steps: [
84
+ { title: "Step 1: Setup", type: "task" },
85
+ { title: "Step 2: Implementation", type: "task" },
86
+ { title: "Step 3: Testing", type: "task" },
87
+ ],
88
+ });
89
+
90
+ expect(molId).toBe("mol-123");
91
+ expect(callCount).toBe(4); // 1 create + 3 step adds
92
+ });
93
+
94
+ test("creates a prototype with default type (task)", async () => {
95
+ let callCount = 0;
96
+ mockSpawn.mockImplementation(((command: string, args: string[]) => {
97
+ callCount++;
98
+ const cmd = [command, ...(args ?? [])];
99
+ if (callCount === 1) {
100
+ return makeMockProcess(JSON.stringify({ id: "mol-456" }));
101
+ }
102
+ // Check that default type is "task"
103
+ expect(cmd).toContain("--type");
104
+ const typeIndex = cmd.indexOf("--type");
105
+ expect(cmd[typeIndex + 1]).toBe("task");
106
+ return makeMockProcess(JSON.stringify({ success: true }));
107
+ }) as unknown as typeof spawn);
108
+
109
+ const molId = await createMoleculePrototype("/test/dir", {
110
+ name: "Default Type Workflow",
111
+ steps: [{ title: "Step without explicit type" }],
112
+ });
113
+
114
+ expect(molId).toBe("mol-456");
115
+ expect(callCount).toBe(2); // 1 create + 1 step add
116
+ });
117
+
118
+ test("creates a prototype with empty steps array", async () => {
119
+ mockSpawn.mockImplementation((() => {
120
+ return makeMockProcess(JSON.stringify({ id: "mol-empty" }));
121
+ }) as unknown as typeof spawn);
122
+
123
+ const molId = await createMoleculePrototype("/test/dir", {
124
+ name: "Empty Workflow",
125
+ steps: [],
126
+ });
127
+
128
+ expect(molId).toBe("mol-empty");
129
+ });
130
+
131
+ test("throws AgentError on create failure", async () => {
132
+ mockSpawn.mockImplementation((() => {
133
+ return makeMockProcess("", "bd mol create failed: invalid name", 1);
134
+ }) as unknown as typeof spawn);
135
+
136
+ await expect(
137
+ createMoleculePrototype("/test/dir", {
138
+ name: "Bad",
139
+ steps: [],
140
+ }),
141
+ ).rejects.toThrow(AgentError);
142
+ });
143
+
144
+ test("throws AgentError on step add failure", async () => {
145
+ let callCount = 0;
146
+ mockSpawn.mockImplementation((() => {
147
+ callCount++;
148
+ if (callCount === 1) {
149
+ return makeMockProcess(JSON.stringify({ id: "mol-789" }));
150
+ }
151
+ // Step add fails
152
+ return makeMockProcess("", "step add failed", 1);
153
+ }) as unknown as typeof spawn);
154
+
155
+ await expect(
156
+ createMoleculePrototype("/test/dir", {
157
+ name: "Test",
158
+ steps: [{ title: "Step 1" }],
159
+ }),
160
+ ).rejects.toThrow(AgentError);
161
+ });
162
+ });
163
+
164
+ describe("listPrototypes", () => {
165
+ test("returns all created prototypes", async () => {
166
+ mockSpawn.mockImplementation((() => {
167
+ return makeMockProcess(
168
+ JSON.stringify([
169
+ { id: "mol-1", name: "List Test 1", stepCount: 1 },
170
+ { id: "mol-2", name: "List Test 2", stepCount: 2 },
171
+ ]),
172
+ );
173
+ }) as unknown as typeof spawn);
174
+
175
+ const prototypes = await listPrototypes("/test/dir");
176
+
177
+ expect(prototypes).toHaveLength(2);
178
+ expect(prototypes[0]).toEqual({ id: "mol-1", name: "List Test 1", stepCount: 1 });
179
+ expect(prototypes[1]).toEqual({ id: "mol-2", name: "List Test 2", stepCount: 2 });
180
+ });
181
+
182
+ test("returns empty array when no prototypes exist", async () => {
183
+ mockSpawn.mockImplementation((() => {
184
+ return makeMockProcess(JSON.stringify([]));
185
+ }) as unknown as typeof spawn);
186
+
187
+ const prototypes = await listPrototypes("/test/dir");
188
+
189
+ expect(Array.isArray(prototypes)).toBe(true);
190
+ expect(prototypes).toHaveLength(0);
191
+ });
192
+
193
+ test("throws AgentError on failure", async () => {
194
+ mockSpawn.mockImplementation((() => {
195
+ return makeMockProcess("", "bd mol list failed", 1);
196
+ }) as unknown as typeof spawn);
197
+
198
+ await expect(listPrototypes("/test/dir")).rejects.toThrow(AgentError);
199
+ });
200
+
201
+ test("throws AgentError on empty output", async () => {
202
+ mockSpawn.mockImplementation((() => {
203
+ return makeMockProcess("");
204
+ }) as unknown as typeof spawn);
205
+
206
+ await expect(listPrototypes("/test/dir")).rejects.toThrow(AgentError);
207
+ });
208
+ });
209
+
210
+ describe("pourMolecule", () => {
211
+ test("pours a prototype into actual issues", async () => {
212
+ mockSpawn.mockImplementation(((command: string, args: string[]) => {
213
+ const cmd = [command, ...(args ?? [])];
214
+ expect(cmd).toEqual(["bd", "mol", "pour", "mol-123", "--json"]);
215
+ return makeMockProcess(JSON.stringify({ ids: ["issue-1", "issue-2", "issue-3"] }));
216
+ }) as unknown as typeof spawn);
217
+
218
+ const issueIds = await pourMolecule("/test/dir", {
219
+ prototypeId: "mol-123",
220
+ });
221
+
222
+ expect(Array.isArray(issueIds)).toBe(true);
223
+ expect(issueIds).toHaveLength(3);
224
+ expect(issueIds).toEqual(["issue-1", "issue-2", "issue-3"]);
225
+ });
226
+
227
+ test("applies prefix when provided", async () => {
228
+ mockSpawn.mockImplementation(((command: string, args: string[]) => {
229
+ const cmd = [command, ...(args ?? [])];
230
+ expect(cmd).toEqual(["bd", "mol", "pour", "mol-456", "--json", "--prefix", "v2.0"]);
231
+ return makeMockProcess(JSON.stringify({ ids: ["issue-4", "issue-5"] }));
232
+ }) as unknown as typeof spawn);
233
+
234
+ const issueIds = await pourMolecule("/test/dir", {
235
+ prototypeId: "mol-456",
236
+ prefix: "v2.0",
237
+ });
238
+
239
+ expect(issueIds).toEqual(["issue-4", "issue-5"]);
240
+ });
241
+
242
+ test("handles empty prototype (0 steps)", async () => {
243
+ mockSpawn.mockImplementation((() => {
244
+ return makeMockProcess(JSON.stringify({ ids: [] }));
245
+ }) as unknown as typeof spawn);
246
+
247
+ const issueIds = await pourMolecule("/test/dir", {
248
+ prototypeId: "mol-empty",
249
+ });
250
+
251
+ expect(Array.isArray(issueIds)).toBe(true);
252
+ expect(issueIds).toHaveLength(0);
253
+ });
254
+
255
+ test("throws AgentError for nonexistent prototype", async () => {
256
+ mockSpawn.mockImplementation((() => {
257
+ return makeMockProcess("", "prototype not found", 1);
258
+ }) as unknown as typeof spawn);
259
+
260
+ await expect(
261
+ pourMolecule("/test/dir", {
262
+ prototypeId: "nonexistent-mol-id",
263
+ }),
264
+ ).rejects.toThrow(AgentError);
265
+ });
266
+ });
267
+
268
+ describe("getConvoyStatus", () => {
269
+ test("returns status for poured prototype", async () => {
270
+ mockSpawn.mockImplementation(((command: string, args: string[]) => {
271
+ const cmd = [command, ...(args ?? [])];
272
+ expect(cmd).toEqual(["bd", "mol", "status", "mol-123", "--json"]);
273
+ return makeMockProcess(
274
+ JSON.stringify({
275
+ total: 3,
276
+ completed: 1,
277
+ inProgress: 1,
278
+ blocked: 0,
279
+ }),
280
+ );
281
+ }) as unknown as typeof spawn);
282
+
283
+ const status = await getConvoyStatus("/test/dir", "mol-123");
284
+
285
+ expect(status).toBeDefined();
286
+ expect(status.total).toBe(3);
287
+ expect(status.completed).toBe(1);
288
+ expect(status.inProgress).toBe(1);
289
+ expect(status.blocked).toBe(0);
290
+ });
291
+
292
+ test("handles empty poured prototype", async () => {
293
+ mockSpawn.mockImplementation((() => {
294
+ return makeMockProcess(
295
+ JSON.stringify({
296
+ total: 0,
297
+ completed: 0,
298
+ inProgress: 0,
299
+ blocked: 0,
300
+ }),
301
+ );
302
+ }) as unknown as typeof spawn);
303
+
304
+ const status = await getConvoyStatus("/test/dir", "mol-empty");
305
+
306
+ expect(status.total).toBe(0);
307
+ expect(status.completed).toBe(0);
308
+ expect(status.inProgress).toBe(0);
309
+ expect(status.blocked).toBe(0);
310
+ });
311
+
312
+ test("throws AgentError for nonexistent prototype", async () => {
313
+ mockSpawn.mockImplementation((() => {
314
+ return makeMockProcess("", "prototype not found", 1);
315
+ }) as unknown as typeof spawn);
316
+
317
+ await expect(getConvoyStatus("/test/dir", "nonexistent-mol-id")).rejects.toThrow(AgentError);
318
+ });
319
+ });
320
+ });