opencode-swarm-plugin 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.
package/src/beads.ts ADDED
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Beads Module - Type-safe wrappers around the `bd` CLI
3
+ *
4
+ * This module provides validated, type-safe operations for the beads
5
+ * issue tracker. All responses are parsed and validated with Zod schemas.
6
+ *
7
+ * Key principles:
8
+ * - Always use `--json` flag for bd commands
9
+ * - Validate all output with Zod schemas
10
+ * - Throw typed errors on failure
11
+ * - Support atomic epic creation with rollback hints
12
+ */
13
+ import { tool } from "@opencode-ai/plugin";
14
+ import { z } from "zod";
15
+ import {
16
+ BeadSchema,
17
+ BeadCreateArgsSchema,
18
+ BeadUpdateArgsSchema,
19
+ BeadCloseArgsSchema,
20
+ BeadQueryArgsSchema,
21
+ EpicCreateArgsSchema,
22
+ EpicCreateResultSchema,
23
+ type Bead,
24
+ type BeadCreateArgs,
25
+ type EpicCreateResult,
26
+ } from "./schemas";
27
+
28
+ /**
29
+ * Custom error for bead operations
30
+ */
31
+ export class BeadError extends Error {
32
+ constructor(
33
+ message: string,
34
+ public readonly command: string,
35
+ public readonly exitCode?: number,
36
+ public readonly stderr?: string,
37
+ ) {
38
+ super(message);
39
+ this.name = "BeadError";
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Custom error for validation failures
45
+ */
46
+ export class BeadValidationError extends Error {
47
+ constructor(
48
+ message: string,
49
+ public readonly zodError: z.ZodError,
50
+ ) {
51
+ super(message);
52
+ this.name = "BeadValidationError";
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Build a bd create command from args
58
+ */
59
+ function buildCreateCommand(args: BeadCreateArgs): string[] {
60
+ const parts = ["bd", "create", args.title];
61
+
62
+ if (args.type && args.type !== "task") {
63
+ parts.push("-t", args.type);
64
+ }
65
+
66
+ if (args.priority !== undefined && args.priority !== 2) {
67
+ parts.push("-p", args.priority.toString());
68
+ }
69
+
70
+ if (args.description) {
71
+ parts.push("-d", args.description);
72
+ }
73
+
74
+ if (args.parent_id) {
75
+ parts.push("--parent", args.parent_id);
76
+ }
77
+
78
+ parts.push("--json");
79
+ return parts;
80
+ }
81
+
82
+ /**
83
+ * Parse and validate bead JSON output
84
+ * Handles both object and array responses (CLI may return either)
85
+ */
86
+ function parseBead(output: string): Bead {
87
+ try {
88
+ const parsed = JSON.parse(output);
89
+ // CLI commands like `bd close`, `bd update` return arrays even for single items
90
+ const data = Array.isArray(parsed) ? parsed[0] : parsed;
91
+ if (!data) {
92
+ throw new BeadError("No bead data in response", "parse");
93
+ }
94
+ return BeadSchema.parse(data);
95
+ } catch (error) {
96
+ if (error instanceof z.ZodError) {
97
+ throw new BeadValidationError(
98
+ `Invalid bead data: ${error.message}`,
99
+ error,
100
+ );
101
+ }
102
+ if (error instanceof BeadError) {
103
+ throw error;
104
+ }
105
+ throw new BeadError(`Failed to parse bead JSON: ${output}`, "parse");
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Parse and validate array of beads
111
+ */
112
+ function parseBeads(output: string): Bead[] {
113
+ try {
114
+ const parsed = JSON.parse(output);
115
+ return z.array(BeadSchema).parse(parsed);
116
+ } catch (error) {
117
+ if (error instanceof z.ZodError) {
118
+ throw new BeadValidationError(
119
+ `Invalid beads data: ${error.message}`,
120
+ error,
121
+ );
122
+ }
123
+ throw new BeadError(`Failed to parse beads JSON: ${output}`, "parse");
124
+ }
125
+ }
126
+
127
+ // ============================================================================
128
+ // Tool Definitions
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Create a new bead with type-safe validation
133
+ */
134
+ export const beads_create = tool({
135
+ description: "Create a new bead with type-safe validation",
136
+ args: {
137
+ title: tool.schema.string().describe("Bead title"),
138
+ type: tool.schema
139
+ .enum(["bug", "feature", "task", "epic", "chore"])
140
+ .optional()
141
+ .describe("Issue type (default: task)"),
142
+ priority: tool.schema
143
+ .number()
144
+ .min(0)
145
+ .max(3)
146
+ .optional()
147
+ .describe("Priority 0-3 (default: 2)"),
148
+ description: tool.schema.string().optional().describe("Bead description"),
149
+ parent_id: tool.schema
150
+ .string()
151
+ .optional()
152
+ .describe("Parent bead ID for epic children"),
153
+ },
154
+ async execute(args, ctx) {
155
+ const validated = BeadCreateArgsSchema.parse(args);
156
+ const cmdParts = buildCreateCommand(validated);
157
+
158
+ // Execute command
159
+ const result = await Bun.$`${cmdParts}`.quiet().nothrow();
160
+
161
+ if (result.exitCode !== 0) {
162
+ throw new BeadError(
163
+ `Failed to create bead: ${result.stderr.toString()}`,
164
+ cmdParts.join(" "),
165
+ result.exitCode,
166
+ result.stderr.toString(),
167
+ );
168
+ }
169
+
170
+ const bead = parseBead(result.stdout.toString());
171
+ return JSON.stringify(bead, null, 2);
172
+ },
173
+ });
174
+
175
+ /**
176
+ * Create an epic with subtasks in one atomic operation
177
+ */
178
+ export const beads_create_epic = tool({
179
+ description: "Create epic with subtasks in one atomic operation",
180
+ args: {
181
+ epic_title: tool.schema.string().describe("Epic title"),
182
+ epic_description: tool.schema
183
+ .string()
184
+ .optional()
185
+ .describe("Epic description"),
186
+ subtasks: tool.schema
187
+ .array(
188
+ tool.schema.object({
189
+ title: tool.schema.string(),
190
+ priority: tool.schema.number().min(0).max(3).optional(),
191
+ files: tool.schema.array(tool.schema.string()).optional(),
192
+ }),
193
+ )
194
+ .describe("Subtasks to create under the epic"),
195
+ },
196
+ async execute(args, ctx) {
197
+ const validated = EpicCreateArgsSchema.parse(args);
198
+ const created: Bead[] = [];
199
+
200
+ try {
201
+ // 1. Create epic
202
+ const epicCmd = buildCreateCommand({
203
+ title: validated.epic_title,
204
+ type: "epic",
205
+ priority: 1,
206
+ description: validated.epic_description,
207
+ });
208
+
209
+ const epicResult = await Bun.$`${epicCmd}`.quiet().nothrow();
210
+
211
+ if (epicResult.exitCode !== 0) {
212
+ throw new BeadError(
213
+ `Failed to create epic: ${epicResult.stderr.toString()}`,
214
+ epicCmd.join(" "),
215
+ epicResult.exitCode,
216
+ );
217
+ }
218
+
219
+ const epic = parseBead(epicResult.stdout.toString());
220
+ created.push(epic);
221
+
222
+ // 2. Create subtasks
223
+ for (const subtask of validated.subtasks) {
224
+ const subtaskCmd = buildCreateCommand({
225
+ title: subtask.title,
226
+ type: "task",
227
+ priority: subtask.priority ?? 2,
228
+ parent_id: epic.id,
229
+ });
230
+
231
+ const subtaskResult = await Bun.$`${subtaskCmd}`.quiet().nothrow();
232
+
233
+ if (subtaskResult.exitCode !== 0) {
234
+ throw new BeadError(
235
+ `Failed to create subtask: ${subtaskResult.stderr.toString()}`,
236
+ subtaskCmd.join(" "),
237
+ subtaskResult.exitCode,
238
+ );
239
+ }
240
+
241
+ const subtaskBead = parseBead(subtaskResult.stdout.toString());
242
+ created.push(subtaskBead);
243
+ }
244
+
245
+ const result: EpicCreateResult = {
246
+ success: true,
247
+ epic,
248
+ subtasks: created.slice(1),
249
+ };
250
+
251
+ return JSON.stringify(result, null, 2);
252
+ } catch (error) {
253
+ // Partial failure - return what was created with rollback hint
254
+ const rollbackHint = created
255
+ .map((b) => `bd close ${b.id} --reason "Rollback partial epic"`)
256
+ .join("\n");
257
+
258
+ const result: EpicCreateResult = {
259
+ success: false,
260
+ epic: created[0] || ({} as Bead),
261
+ subtasks: created.slice(1),
262
+ rollback_hint: rollbackHint,
263
+ };
264
+
265
+ return JSON.stringify(
266
+ {
267
+ ...result,
268
+ error: error instanceof Error ? error.message : String(error),
269
+ },
270
+ null,
271
+ 2,
272
+ );
273
+ }
274
+ },
275
+ });
276
+
277
+ /**
278
+ * Query beads with filters
279
+ */
280
+ export const beads_query = tool({
281
+ description: "Query beads with filters (replaces bd list, bd ready, bd wip)",
282
+ args: {
283
+ status: tool.schema
284
+ .enum(["open", "in_progress", "blocked", "closed"])
285
+ .optional()
286
+ .describe("Filter by status"),
287
+ type: tool.schema
288
+ .enum(["bug", "feature", "task", "epic", "chore"])
289
+ .optional()
290
+ .describe("Filter by type"),
291
+ ready: tool.schema
292
+ .boolean()
293
+ .optional()
294
+ .describe("Only show unblocked beads (uses bd ready)"),
295
+ limit: tool.schema
296
+ .number()
297
+ .optional()
298
+ .describe("Max results to return (default: 20)"),
299
+ },
300
+ async execute(args, ctx) {
301
+ const validated = BeadQueryArgsSchema.parse(args);
302
+
303
+ let cmd: string[];
304
+
305
+ if (validated.ready) {
306
+ cmd = ["bd", "ready", "--json"];
307
+ } else {
308
+ cmd = ["bd", "list", "--json"];
309
+ if (validated.status) {
310
+ cmd.push("--status", validated.status);
311
+ }
312
+ if (validated.type) {
313
+ cmd.push("--type", validated.type);
314
+ }
315
+ }
316
+
317
+ const result = await Bun.$`${cmd}`.quiet().nothrow();
318
+
319
+ if (result.exitCode !== 0) {
320
+ throw new BeadError(
321
+ `Failed to query beads: ${result.stderr.toString()}`,
322
+ cmd.join(" "),
323
+ result.exitCode,
324
+ );
325
+ }
326
+
327
+ const beads = parseBeads(result.stdout.toString());
328
+ const limited = beads.slice(0, validated.limit);
329
+
330
+ return JSON.stringify(limited, null, 2);
331
+ },
332
+ });
333
+
334
+ /**
335
+ * Update a bead's status or description
336
+ */
337
+ export const beads_update = tool({
338
+ description: "Update bead status/description",
339
+ args: {
340
+ id: tool.schema.string().describe("Bead ID"),
341
+ status: tool.schema
342
+ .enum(["open", "in_progress", "blocked", "closed"])
343
+ .optional()
344
+ .describe("New status"),
345
+ description: tool.schema.string().optional().describe("New description"),
346
+ priority: tool.schema
347
+ .number()
348
+ .min(0)
349
+ .max(3)
350
+ .optional()
351
+ .describe("New priority"),
352
+ },
353
+ async execute(args, ctx) {
354
+ const validated = BeadUpdateArgsSchema.parse(args);
355
+
356
+ const cmd = ["bd", "update", validated.id];
357
+
358
+ if (validated.status) {
359
+ cmd.push("--status", validated.status);
360
+ }
361
+ if (validated.description) {
362
+ cmd.push("-d", validated.description);
363
+ }
364
+ if (validated.priority !== undefined) {
365
+ cmd.push("-p", validated.priority.toString());
366
+ }
367
+ cmd.push("--json");
368
+
369
+ const result = await Bun.$`${cmd}`.quiet().nothrow();
370
+
371
+ if (result.exitCode !== 0) {
372
+ throw new BeadError(
373
+ `Failed to update bead: ${result.stderr.toString()}`,
374
+ cmd.join(" "),
375
+ result.exitCode,
376
+ );
377
+ }
378
+
379
+ const bead = parseBead(result.stdout.toString());
380
+ return JSON.stringify(bead, null, 2);
381
+ },
382
+ });
383
+
384
+ /**
385
+ * Close a bead with reason
386
+ */
387
+ export const beads_close = tool({
388
+ description: "Close a bead with reason",
389
+ args: {
390
+ id: tool.schema.string().describe("Bead ID"),
391
+ reason: tool.schema.string().describe("Completion reason"),
392
+ },
393
+ async execute(args, ctx) {
394
+ const validated = BeadCloseArgsSchema.parse(args);
395
+
396
+ const cmd = [
397
+ "bd",
398
+ "close",
399
+ validated.id,
400
+ "--reason",
401
+ validated.reason,
402
+ "--json",
403
+ ];
404
+
405
+ const result = await Bun.$`${cmd}`.quiet().nothrow();
406
+
407
+ if (result.exitCode !== 0) {
408
+ throw new BeadError(
409
+ `Failed to close bead: ${result.stderr.toString()}`,
410
+ cmd.join(" "),
411
+ result.exitCode,
412
+ );
413
+ }
414
+
415
+ const bead = parseBead(result.stdout.toString());
416
+ return `Closed ${bead.id}: ${validated.reason}`;
417
+ },
418
+ });
419
+
420
+ /**
421
+ * Mark a bead as in-progress
422
+ */
423
+ export const beads_start = tool({
424
+ description:
425
+ "Mark a bead as in-progress (shortcut for update --status in_progress)",
426
+ args: {
427
+ id: tool.schema.string().describe("Bead ID"),
428
+ },
429
+ async execute(args, ctx) {
430
+ const cmd = ["bd", "update", args.id, "--status", "in_progress", "--json"];
431
+
432
+ const result = await Bun.$`${cmd}`.quiet().nothrow();
433
+
434
+ if (result.exitCode !== 0) {
435
+ throw new BeadError(
436
+ `Failed to start bead: ${result.stderr.toString()}`,
437
+ cmd.join(" "),
438
+ result.exitCode,
439
+ );
440
+ }
441
+
442
+ const bead = parseBead(result.stdout.toString());
443
+ return `Started: ${bead.id}`;
444
+ },
445
+ });
446
+
447
+ /**
448
+ * Get the next ready bead
449
+ */
450
+ export const beads_ready = tool({
451
+ description: "Get the next ready bead (unblocked, highest priority)",
452
+ args: {},
453
+ async execute(args, ctx) {
454
+ const cmd = ["bd", "ready", "--json"];
455
+
456
+ const result = await Bun.$`${cmd}`.quiet().nothrow();
457
+
458
+ if (result.exitCode !== 0) {
459
+ throw new BeadError(
460
+ `Failed to get ready beads: ${result.stderr.toString()}`,
461
+ cmd.join(" "),
462
+ result.exitCode,
463
+ );
464
+ }
465
+
466
+ const beads = parseBeads(result.stdout.toString());
467
+
468
+ if (beads.length === 0) {
469
+ return "No ready beads";
470
+ }
471
+
472
+ const next = beads[0];
473
+ return JSON.stringify(next, null, 2);
474
+ },
475
+ });
476
+
477
+ /**
478
+ * Sync beads to git and push
479
+ */
480
+ export const beads_sync = tool({
481
+ description: "Sync beads to git and push (MANDATORY at session end)",
482
+ args: {
483
+ auto_pull: tool.schema
484
+ .boolean()
485
+ .optional()
486
+ .describe("Pull before sync (default: true)"),
487
+ },
488
+ async execute(args, ctx) {
489
+ const autoPull = args.auto_pull ?? true;
490
+
491
+ // 1. Pull if requested
492
+ if (autoPull) {
493
+ const pullResult = await Bun.$`git pull --rebase`.quiet().nothrow();
494
+ if (pullResult.exitCode !== 0) {
495
+ throw new BeadError(
496
+ `Failed to pull: ${pullResult.stderr.toString()}`,
497
+ "git pull --rebase",
498
+ pullResult.exitCode,
499
+ );
500
+ }
501
+ }
502
+
503
+ // 2. Sync beads
504
+ const syncResult = await Bun.$`bd sync`.quiet().nothrow();
505
+ if (syncResult.exitCode !== 0) {
506
+ throw new BeadError(
507
+ `Failed to sync beads: ${syncResult.stderr.toString()}`,
508
+ "bd sync",
509
+ syncResult.exitCode,
510
+ );
511
+ }
512
+
513
+ // 3. Push
514
+ const pushResult = await Bun.$`git push`.quiet().nothrow();
515
+ if (pushResult.exitCode !== 0) {
516
+ throw new BeadError(
517
+ `Failed to push: ${pushResult.stderr.toString()}`,
518
+ "git push",
519
+ pushResult.exitCode,
520
+ );
521
+ }
522
+
523
+ // 4. Verify clean state
524
+ const statusResult = await Bun.$`git status --porcelain`.quiet().nothrow();
525
+ const status = statusResult.stdout.toString().trim();
526
+
527
+ if (status !== "") {
528
+ return `Beads synced and pushed, but working directory not clean:\n${status}`;
529
+ }
530
+
531
+ return "Beads synced and pushed successfully";
532
+ },
533
+ });
534
+
535
+ /**
536
+ * Link a bead to an Agent Mail thread
537
+ */
538
+ export const beads_link_thread = tool({
539
+ description: "Add metadata linking bead to Agent Mail thread",
540
+ args: {
541
+ bead_id: tool.schema.string().describe("Bead ID"),
542
+ thread_id: tool.schema.string().describe("Agent Mail thread ID"),
543
+ },
544
+ async execute(args, ctx) {
545
+ // Update bead description to include thread link
546
+ // This is a workaround since bd doesn't have native metadata support
547
+ const queryResult = await Bun.$`bd show ${args.bead_id} --json`
548
+ .quiet()
549
+ .nothrow();
550
+
551
+ if (queryResult.exitCode !== 0) {
552
+ throw new BeadError(
553
+ `Failed to get bead: ${queryResult.stderr.toString()}`,
554
+ `bd show ${args.bead_id} --json`,
555
+ queryResult.exitCode,
556
+ );
557
+ }
558
+
559
+ const bead = parseBead(queryResult.stdout.toString());
560
+ const existingDesc = bead.description || "";
561
+
562
+ // Add thread link if not already present
563
+ const threadMarker = `[thread:${args.thread_id}]`;
564
+ if (existingDesc.includes(threadMarker)) {
565
+ return `Bead ${args.bead_id} already linked to thread ${args.thread_id}`;
566
+ }
567
+
568
+ const newDesc = existingDesc
569
+ ? `${existingDesc}\n\n${threadMarker}`
570
+ : threadMarker;
571
+
572
+ const updateResult =
573
+ await Bun.$`bd update ${args.bead_id} -d ${newDesc} --json`
574
+ .quiet()
575
+ .nothrow();
576
+
577
+ if (updateResult.exitCode !== 0) {
578
+ throw new BeadError(
579
+ `Failed to update bead: ${updateResult.stderr.toString()}`,
580
+ `bd update ${args.bead_id} -d ...`,
581
+ updateResult.exitCode,
582
+ );
583
+ }
584
+
585
+ return `Linked bead ${args.bead_id} to thread ${args.thread_id}`;
586
+ },
587
+ });
588
+
589
+ // ============================================================================
590
+ // Export all tools
591
+ // ============================================================================
592
+
593
+ export const beadsTools = {
594
+ beads_create: beads_create,
595
+ beads_create_epic: beads_create_epic,
596
+ beads_query: beads_query,
597
+ beads_update: beads_update,
598
+ beads_close: beads_close,
599
+ beads_start: beads_start,
600
+ beads_ready: beads_ready,
601
+ beads_sync: beads_sync,
602
+ beads_link_thread: beads_link_thread,
603
+ };