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/swarm.ts ADDED
@@ -0,0 +1,1411 @@
1
+ /**
2
+ * Swarm Module - High-level swarm coordination
3
+ *
4
+ * Orchestrates beads, Agent Mail, and structured validation for parallel task execution.
5
+ * The actual agent spawning happens via OpenCode's Task tool - this module provides
6
+ * the primitives and prompts that /swarm command uses.
7
+ *
8
+ * Key responsibilities:
9
+ * - Task decomposition into bead trees with file assignments
10
+ * - Swarm status tracking via beads + Agent Mail
11
+ * - Progress reporting and completion handling
12
+ * - Prompt templates for decomposition, subtasks, and evaluation
13
+ */
14
+ import { tool } from "@opencode-ai/plugin";
15
+ import { z } from "zod";
16
+ import {
17
+ BeadTreeSchema,
18
+ SwarmStatusSchema,
19
+ AgentProgressSchema,
20
+ EvaluationSchema,
21
+ BeadSchema,
22
+ type SwarmStatus,
23
+ type AgentProgress,
24
+ type Evaluation,
25
+ type SpawnedAgent,
26
+ type Bead,
27
+ } from "./schemas";
28
+ import { mcpCall } from "./agent-mail";
29
+ import {
30
+ OutcomeSignalsSchema,
31
+ scoreImplicitFeedback,
32
+ outcomeToFeedback,
33
+ type OutcomeSignals,
34
+ type ScoredOutcome,
35
+ type FeedbackEvent,
36
+ DEFAULT_LEARNING_CONFIG,
37
+ } from "./learning";
38
+
39
+ // ============================================================================
40
+ // Conflict Detection
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Marker words that indicate positive directives
45
+ */
46
+ const POSITIVE_MARKERS = [
47
+ "always",
48
+ "must",
49
+ "required",
50
+ "ensure",
51
+ "use",
52
+ "prefer",
53
+ ];
54
+
55
+ /**
56
+ * Marker words that indicate negative directives
57
+ */
58
+ const NEGATIVE_MARKERS = [
59
+ "never",
60
+ "dont",
61
+ "don't",
62
+ "avoid",
63
+ "forbid",
64
+ "no ",
65
+ "not ",
66
+ ];
67
+
68
+ /**
69
+ * A detected conflict between subtask instructions
70
+ */
71
+ export interface InstructionConflict {
72
+ subtask_a: number;
73
+ subtask_b: number;
74
+ directive_a: string;
75
+ directive_b: string;
76
+ conflict_type: "positive_negative" | "contradictory";
77
+ description: string;
78
+ }
79
+
80
+ /**
81
+ * Extract directives from text based on marker words
82
+ */
83
+ function extractDirectives(text: string): {
84
+ positive: string[];
85
+ negative: string[];
86
+ } {
87
+ const sentences = text.split(/[.!?\n]+/).map((s) => s.trim().toLowerCase());
88
+ const positive: string[] = [];
89
+ const negative: string[] = [];
90
+
91
+ for (const sentence of sentences) {
92
+ if (!sentence) continue;
93
+
94
+ const hasPositive = POSITIVE_MARKERS.some((m) => sentence.includes(m));
95
+ const hasNegative = NEGATIVE_MARKERS.some((m) => sentence.includes(m));
96
+
97
+ if (hasPositive && !hasNegative) {
98
+ positive.push(sentence);
99
+ } else if (hasNegative) {
100
+ negative.push(sentence);
101
+ }
102
+ }
103
+
104
+ return { positive, negative };
105
+ }
106
+
107
+ /**
108
+ * Check if two directives conflict
109
+ *
110
+ * Simple heuristic: look for common subjects with opposite polarity
111
+ */
112
+ function directivesConflict(positive: string, negative: string): boolean {
113
+ // Extract key nouns/concepts (simple word overlap check)
114
+ const positiveWords = new Set(
115
+ positive.split(/\s+/).filter((w) => w.length > 3),
116
+ );
117
+ const negativeWords = negative.split(/\s+/).filter((w) => w.length > 3);
118
+
119
+ // If they share significant words, they might conflict
120
+ const overlap = negativeWords.filter((w) => positiveWords.has(w));
121
+ return overlap.length >= 2;
122
+ }
123
+
124
+ /**
125
+ * Detect conflicts between subtask instructions
126
+ *
127
+ * Looks for cases where one subtask says "always use X" and another says "avoid X".
128
+ *
129
+ * @param subtasks - Array of subtask descriptions
130
+ * @returns Array of detected conflicts
131
+ *
132
+ * @see https://github.com/Dicklesworthstone/cass_memory_system/blob/main/src/curate.ts#L36-L89
133
+ */
134
+ export function detectInstructionConflicts(
135
+ subtasks: Array<{ title: string; description?: string }>,
136
+ ): InstructionConflict[] {
137
+ const conflicts: InstructionConflict[] = [];
138
+
139
+ // Extract directives from each subtask
140
+ const subtaskDirectives = subtasks.map((s, i) => ({
141
+ index: i,
142
+ title: s.title,
143
+ ...extractDirectives(`${s.title} ${s.description || ""}`),
144
+ }));
145
+
146
+ // Compare each pair of subtasks
147
+ for (let i = 0; i < subtaskDirectives.length; i++) {
148
+ for (let j = i + 1; j < subtaskDirectives.length; j++) {
149
+ const a = subtaskDirectives[i];
150
+ const b = subtaskDirectives[j];
151
+
152
+ // Check if A's positive conflicts with B's negative
153
+ for (const posA of a.positive) {
154
+ for (const negB of b.negative) {
155
+ if (directivesConflict(posA, negB)) {
156
+ conflicts.push({
157
+ subtask_a: i,
158
+ subtask_b: j,
159
+ directive_a: posA,
160
+ directive_b: negB,
161
+ conflict_type: "positive_negative",
162
+ description: `Subtask ${i} says "${posA}" but subtask ${j} says "${negB}"`,
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ // Check if B's positive conflicts with A's negative
169
+ for (const posB of b.positive) {
170
+ for (const negA of a.negative) {
171
+ if (directivesConflict(posB, negA)) {
172
+ conflicts.push({
173
+ subtask_a: j,
174
+ subtask_b: i,
175
+ directive_a: posB,
176
+ directive_b: negA,
177
+ conflict_type: "positive_negative",
178
+ description: `Subtask ${j} says "${posB}" but subtask ${i} says "${negA}"`,
179
+ });
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ return conflicts;
187
+ }
188
+
189
+ // ============================================================================
190
+ // Prompt Templates
191
+ // ============================================================================
192
+
193
+ /**
194
+ * Prompt for decomposing a task into parallelizable subtasks.
195
+ *
196
+ * Used by swarm:decompose to instruct the agent on how to break down work.
197
+ * The agent responds with a BeadTree that gets validated.
198
+ */
199
+ export const DECOMPOSITION_PROMPT = `You are decomposing a task into parallelizable subtasks for a swarm of agents.
200
+
201
+ ## Task
202
+ {task}
203
+
204
+ {context_section}
205
+
206
+ ## Requirements
207
+
208
+ 1. **Break into 2-{max_subtasks} independent subtasks** that can run in parallel
209
+ 2. **Assign files** - each subtask must specify which files it will modify
210
+ 3. **No file overlap** - files cannot appear in multiple subtasks (they get exclusive locks)
211
+ 4. **Order by dependency** - if subtask B needs subtask A's output, A must come first in the array
212
+ 5. **Estimate complexity** - 1 (trivial) to 5 (complex)
213
+
214
+ ## Response Format
215
+
216
+ Respond with a JSON object matching this schema:
217
+
218
+ \`\`\`typescript
219
+ {
220
+ epic: {
221
+ title: string, // Epic title for the beads tracker
222
+ description?: string // Brief description of the overall goal
223
+ },
224
+ subtasks: [
225
+ {
226
+ title: string, // What this subtask accomplishes
227
+ description?: string, // Detailed instructions for the agent
228
+ files: string[], // Files this subtask will modify (globs allowed)
229
+ dependencies: number[], // Indices of subtasks this depends on (0-indexed)
230
+ estimated_complexity: 1-5 // Effort estimate
231
+ },
232
+ // ... more subtasks
233
+ ]
234
+ }
235
+ \`\`\`
236
+
237
+ ## Guidelines
238
+
239
+ - **Prefer smaller, focused subtasks** over large complex ones
240
+ - **Include test files** in the same subtask as the code they test
241
+ - **Consider shared types** - if multiple files share types, handle that first
242
+ - **Think about imports** - changes to exported APIs affect downstream files
243
+
244
+ ## File Assignment Examples
245
+
246
+ - Schema change: \`["src/schemas/user.ts", "src/schemas/index.ts"]\`
247
+ - Component + test: \`["src/components/Button.tsx", "src/components/Button.test.tsx"]\`
248
+ - API route: \`["src/app/api/users/route.ts"]\`
249
+
250
+ Now decompose the task:`;
251
+
252
+ /**
253
+ * Prompt template for spawned subtask agents.
254
+ *
255
+ * Each agent receives this prompt with their specific subtask details filled in.
256
+ * The prompt establishes context, constraints, and expectations.
257
+ */
258
+ export const SUBTASK_PROMPT = `You are a swarm agent working on a subtask of a larger epic.
259
+
260
+ ## Your Identity
261
+ - **Agent Name**: {agent_name}
262
+ - **Bead ID**: {bead_id}
263
+ - **Epic ID**: {epic_id}
264
+
265
+ ## Your Subtask
266
+ **Title**: {subtask_title}
267
+
268
+ {subtask_description}
269
+
270
+ ## File Scope
271
+ You have exclusive reservations for these files:
272
+ {file_list}
273
+
274
+ **CRITICAL**: Only modify files in your reservation. If you need to modify other files,
275
+ send a message to the coordinator requesting the change.
276
+
277
+ ## Shared Context
278
+ {shared_context}
279
+
280
+ ## Coordination Protocol
281
+
282
+ 1. **Start**: Your bead is already marked in_progress
283
+ 2. **Progress**: Use swarm:progress to report status updates
284
+ 3. **Blocked**: If you hit a blocker, report it - don't spin
285
+ 4. **Complete**: Use swarm:complete when done - it handles:
286
+ - Closing your bead with a summary
287
+ - Releasing file reservations
288
+ - Notifying the coordinator
289
+
290
+ ## Self-Evaluation
291
+
292
+ Before calling swarm:complete, evaluate your work:
293
+ - Type safety: Does it compile without errors?
294
+ - No obvious bugs: Did you handle edge cases?
295
+ - Follows patterns: Does it match existing code style?
296
+ - Readable: Would another developer understand it?
297
+
298
+ If evaluation fails, fix the issues before completing.
299
+
300
+ ## Communication
301
+
302
+ To message other agents or the coordinator:
303
+ \`\`\`
304
+ agent-mail:send(
305
+ to: ["coordinator_name" or other agent],
306
+ subject: "Brief subject",
307
+ body: "Message content",
308
+ thread_id: "{epic_id}"
309
+ )
310
+ \`\`\`
311
+
312
+ Begin work on your subtask now.`;
313
+
314
+ /**
315
+ * Prompt for self-evaluation before completing a subtask.
316
+ *
317
+ * Agents use this to assess their work quality before marking complete.
318
+ */
319
+ export const EVALUATION_PROMPT = `Evaluate the work completed for this subtask.
320
+
321
+ ## Subtask
322
+ **Bead ID**: {bead_id}
323
+ **Title**: {subtask_title}
324
+
325
+ ## Files Modified
326
+ {files_touched}
327
+
328
+ ## Evaluation Criteria
329
+
330
+ For each criterion, assess passed/failed and provide brief feedback:
331
+
332
+ 1. **type_safe**: Code compiles without TypeScript errors
333
+ 2. **no_bugs**: No obvious bugs, edge cases handled
334
+ 3. **patterns**: Follows existing codebase patterns and conventions
335
+ 4. **readable**: Code is clear and maintainable
336
+
337
+ ## Response Format
338
+
339
+ \`\`\`json
340
+ {
341
+ "passed": boolean, // Overall pass/fail
342
+ "criteria": {
343
+ "type_safe": { "passed": boolean, "feedback": string },
344
+ "no_bugs": { "passed": boolean, "feedback": string },
345
+ "patterns": { "passed": boolean, "feedback": string },
346
+ "readable": { "passed": boolean, "feedback": string }
347
+ },
348
+ "overall_feedback": string,
349
+ "retry_suggestion": string | null // If failed, what to fix
350
+ }
351
+ \`\`\`
352
+
353
+ If any criterion fails, the overall evaluation fails and retry_suggestion
354
+ should describe what needs to be fixed.`;
355
+
356
+ // ============================================================================
357
+ // Errors
358
+ // ============================================================================
359
+
360
+ export class SwarmError extends Error {
361
+ constructor(
362
+ message: string,
363
+ public readonly operation: string,
364
+ public readonly details?: unknown,
365
+ ) {
366
+ super(message);
367
+ this.name = "SwarmError";
368
+ }
369
+ }
370
+
371
+ export class DecompositionError extends SwarmError {
372
+ constructor(
373
+ message: string,
374
+ public readonly zodError?: z.ZodError,
375
+ ) {
376
+ super(message, "decompose", zodError?.issues);
377
+ }
378
+ }
379
+
380
+ // ============================================================================
381
+ // Helper Functions
382
+ // ============================================================================
383
+
384
+ /**
385
+ * Format the decomposition prompt with actual values
386
+ */
387
+ function formatDecompositionPrompt(
388
+ task: string,
389
+ maxSubtasks: number,
390
+ context?: string,
391
+ ): string {
392
+ const contextSection = context
393
+ ? `## Additional Context\n${context}`
394
+ : "## Additional Context\n(none provided)";
395
+
396
+ return DECOMPOSITION_PROMPT.replace("{task}", task)
397
+ .replace("{max_subtasks}", maxSubtasks.toString())
398
+ .replace("{context_section}", contextSection);
399
+ }
400
+
401
+ /**
402
+ * Format the subtask prompt for a specific agent
403
+ */
404
+ export function formatSubtaskPrompt(params: {
405
+ agent_name: string;
406
+ bead_id: string;
407
+ epic_id: string;
408
+ subtask_title: string;
409
+ subtask_description: string;
410
+ files: string[];
411
+ shared_context?: string;
412
+ }): string {
413
+ const fileList = params.files.map((f) => `- \`${f}\``).join("\n");
414
+
415
+ return SUBTASK_PROMPT.replace("{agent_name}", params.agent_name)
416
+ .replace("{bead_id}", params.bead_id)
417
+ .replace(/{epic_id}/g, params.epic_id)
418
+ .replace("{subtask_title}", params.subtask_title)
419
+ .replace("{subtask_description}", params.subtask_description || "(none)")
420
+ .replace("{file_list}", fileList || "(no files assigned)")
421
+ .replace("{shared_context}", params.shared_context || "(none)");
422
+ }
423
+
424
+ /**
425
+ * Format the evaluation prompt
426
+ */
427
+ export function formatEvaluationPrompt(params: {
428
+ bead_id: string;
429
+ subtask_title: string;
430
+ files_touched: string[];
431
+ }): string {
432
+ const filesList = params.files_touched.map((f) => `- \`${f}\``).join("\n");
433
+
434
+ return EVALUATION_PROMPT.replace("{bead_id}", params.bead_id)
435
+ .replace("{subtask_title}", params.subtask_title)
436
+ .replace("{files_touched}", filesList || "(no files recorded)");
437
+ }
438
+
439
+ /**
440
+ * Query beads for subtasks of an epic
441
+ */
442
+ async function queryEpicSubtasks(epicId: string): Promise<Bead[]> {
443
+ const result = await Bun.$`bd list --parent ${epicId} --json`
444
+ .quiet()
445
+ .nothrow();
446
+
447
+ if (result.exitCode !== 0) {
448
+ throw new SwarmError(
449
+ `Failed to query subtasks: ${result.stderr.toString()}`,
450
+ "query_subtasks",
451
+ );
452
+ }
453
+
454
+ try {
455
+ const parsed = JSON.parse(result.stdout.toString());
456
+ return z.array(BeadSchema).parse(parsed);
457
+ } catch (error) {
458
+ if (error instanceof z.ZodError) {
459
+ throw new SwarmError(
460
+ `Invalid bead data: ${error.message}`,
461
+ "query_subtasks",
462
+ error.issues,
463
+ );
464
+ }
465
+ throw error;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Query Agent Mail for swarm thread messages
471
+ */
472
+ async function querySwarmMessages(
473
+ projectKey: string,
474
+ threadId: string,
475
+ ): Promise<number> {
476
+ try {
477
+ interface ThreadSummary {
478
+ summary: { total_messages: number };
479
+ }
480
+ const summary = await mcpCall<ThreadSummary>("summarize_thread", {
481
+ project_key: projectKey,
482
+ thread_id: threadId,
483
+ llm_mode: false, // Just need the count
484
+ });
485
+ return summary.summary.total_messages;
486
+ } catch {
487
+ // Thread might not exist yet
488
+ return 0;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Format a progress message for Agent Mail
494
+ */
495
+ function formatProgressMessage(progress: AgentProgress): string {
496
+ const lines = [
497
+ `**Status**: ${progress.status}`,
498
+ progress.progress_percent !== undefined
499
+ ? `**Progress**: ${progress.progress_percent}%`
500
+ : null,
501
+ progress.message ? `**Message**: ${progress.message}` : null,
502
+ progress.files_touched && progress.files_touched.length > 0
503
+ ? `**Files touched**:\n${progress.files_touched.map((f) => `- \`${f}\``).join("\n")}`
504
+ : null,
505
+ progress.blockers && progress.blockers.length > 0
506
+ ? `**Blockers**:\n${progress.blockers.map((b) => `- ${b}`).join("\n")}`
507
+ : null,
508
+ ];
509
+
510
+ return lines.filter(Boolean).join("\n\n");
511
+ }
512
+
513
+ // ============================================================================
514
+ // CASS History Integration
515
+ // ============================================================================
516
+
517
+ /**
518
+ * CASS search result from similar past tasks
519
+ */
520
+ interface CassSearchResult {
521
+ query: string;
522
+ results: Array<{
523
+ source_path: string;
524
+ line: number;
525
+ agent: string;
526
+ preview: string;
527
+ score: number;
528
+ }>;
529
+ }
530
+
531
+ /**
532
+ * Query CASS for similar past tasks
533
+ *
534
+ * @param task - Task description to search for
535
+ * @param limit - Maximum results to return
536
+ * @returns Search results or null if CASS unavailable
537
+ */
538
+ async function queryCassHistory(
539
+ task: string,
540
+ limit: number = 3,
541
+ ): Promise<CassSearchResult | null> {
542
+ try {
543
+ const result = await Bun.$`cass search ${task} --limit ${limit} --json`
544
+ .quiet()
545
+ .nothrow();
546
+
547
+ if (result.exitCode === 127) {
548
+ // CASS not installed
549
+ return null;
550
+ }
551
+
552
+ if (result.exitCode !== 0) {
553
+ return null;
554
+ }
555
+
556
+ const output = result.stdout.toString();
557
+ if (!output.trim()) {
558
+ return { query: task, results: [] };
559
+ }
560
+
561
+ try {
562
+ const parsed = JSON.parse(output);
563
+ return {
564
+ query: task,
565
+ results: Array.isArray(parsed) ? parsed : parsed.results || [],
566
+ };
567
+ } catch {
568
+ return { query: task, results: [] };
569
+ }
570
+ } catch {
571
+ return null;
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Format CASS history for inclusion in decomposition prompt
577
+ */
578
+ function formatCassHistoryForPrompt(history: CassSearchResult): string {
579
+ if (history.results.length === 0) {
580
+ return "";
581
+ }
582
+
583
+ const lines = [
584
+ "## Similar Past Tasks",
585
+ "",
586
+ "These similar tasks were found in agent history:",
587
+ "",
588
+ ...history.results.slice(0, 3).map((r, i) => {
589
+ const preview = r.preview.slice(0, 200).replace(/\n/g, " ");
590
+ return `${i + 1}. [${r.agent}] ${preview}...`;
591
+ }),
592
+ "",
593
+ "Consider patterns that worked in these past tasks.",
594
+ "",
595
+ ];
596
+
597
+ return lines.join("\n");
598
+ }
599
+
600
+ // ============================================================================
601
+ // Tool Definitions
602
+ // ============================================================================
603
+
604
+ /**
605
+ * Decompose a task into a bead tree
606
+ *
607
+ * This is a PROMPT tool - it returns a prompt for the agent to respond to.
608
+ * The agent's response (JSON) should be validated with BeadTreeSchema.
609
+ *
610
+ * Optionally queries CASS for similar past tasks to inform decomposition.
611
+ */
612
+ export const swarm_decompose = tool({
613
+ description:
614
+ "Generate decomposition prompt for breaking task into parallelizable subtasks. Optionally queries CASS for similar past tasks.",
615
+ args: {
616
+ task: tool.schema.string().min(1).describe("Task description to decompose"),
617
+ max_subtasks: tool.schema
618
+ .number()
619
+ .int()
620
+ .min(2)
621
+ .max(10)
622
+ .default(5)
623
+ .describe("Maximum number of subtasks (default: 5)"),
624
+ context: tool.schema
625
+ .string()
626
+ .optional()
627
+ .describe("Additional context (codebase info, constraints, etc.)"),
628
+ query_cass: tool.schema
629
+ .boolean()
630
+ .optional()
631
+ .describe("Query CASS for similar past tasks (default: true)"),
632
+ cass_limit: tool.schema
633
+ .number()
634
+ .int()
635
+ .min(1)
636
+ .max(10)
637
+ .optional()
638
+ .describe("Max CASS results to include (default: 3)"),
639
+ },
640
+ async execute(args) {
641
+ // Query CASS for similar past tasks
642
+ let cassContext = "";
643
+ let cassResult: CassSearchResult | null = null;
644
+
645
+ if (args.query_cass !== false) {
646
+ cassResult = await queryCassHistory(args.task, args.cass_limit ?? 3);
647
+ if (cassResult && cassResult.results.length > 0) {
648
+ cassContext = formatCassHistoryForPrompt(cassResult);
649
+ }
650
+ }
651
+
652
+ // Combine user context with CASS history
653
+ const fullContext = [args.context, cassContext]
654
+ .filter(Boolean)
655
+ .join("\n\n");
656
+
657
+ const prompt = formatDecompositionPrompt(
658
+ args.task,
659
+ args.max_subtasks ?? 5,
660
+ fullContext || undefined,
661
+ );
662
+
663
+ // Return the prompt and schema info for the caller
664
+ return JSON.stringify(
665
+ {
666
+ prompt,
667
+ expected_schema: "BeadTree",
668
+ schema_hint: {
669
+ epic: { title: "string", description: "string?" },
670
+ subtasks: [
671
+ {
672
+ title: "string",
673
+ description: "string?",
674
+ files: "string[]",
675
+ dependencies: "number[]",
676
+ estimated_complexity: "1-5",
677
+ },
678
+ ],
679
+ },
680
+ validation_note:
681
+ "Parse agent response as JSON and validate with BeadTreeSchema from schemas/bead.ts",
682
+ cass_history: cassResult
683
+ ? {
684
+ queried: true,
685
+ results_found: cassResult.results.length,
686
+ included_in_context: cassResult.results.length > 0,
687
+ }
688
+ : { queried: false, reason: "disabled or unavailable" },
689
+ },
690
+ null,
691
+ 2,
692
+ );
693
+ },
694
+ });
695
+
696
+ /**
697
+ * Validate a decomposition response from an agent
698
+ *
699
+ * Use this after the agent responds to swarm:decompose to validate the structure.
700
+ */
701
+ export const swarm_validate_decomposition = tool({
702
+ description: "Validate a decomposition response against BeadTreeSchema",
703
+ args: {
704
+ response: tool.schema
705
+ .string()
706
+ .describe("JSON response from agent (BeadTree format)"),
707
+ },
708
+ async execute(args) {
709
+ try {
710
+ const parsed = JSON.parse(args.response);
711
+ const validated = BeadTreeSchema.parse(parsed);
712
+
713
+ // Additional validation: check for file conflicts
714
+ const allFiles = new Set<string>();
715
+ const conflicts: string[] = [];
716
+
717
+ for (const subtask of validated.subtasks) {
718
+ for (const file of subtask.files) {
719
+ if (allFiles.has(file)) {
720
+ conflicts.push(file);
721
+ }
722
+ allFiles.add(file);
723
+ }
724
+ }
725
+
726
+ if (conflicts.length > 0) {
727
+ return JSON.stringify(
728
+ {
729
+ valid: false,
730
+ error: `File conflicts detected: ${conflicts.join(", ")}`,
731
+ hint: "Each file can only be assigned to one subtask",
732
+ },
733
+ null,
734
+ 2,
735
+ );
736
+ }
737
+
738
+ // Check dependency indices are valid
739
+ for (let i = 0; i < validated.subtasks.length; i++) {
740
+ const deps = validated.subtasks[i].dependencies;
741
+ for (const dep of deps) {
742
+ if (dep >= i) {
743
+ return JSON.stringify(
744
+ {
745
+ valid: false,
746
+ error: `Invalid dependency: subtask ${i} depends on ${dep}, but dependencies must be earlier in the array`,
747
+ hint: "Reorder subtasks so dependencies come before dependents",
748
+ },
749
+ null,
750
+ 2,
751
+ );
752
+ }
753
+ }
754
+ }
755
+
756
+ // Check for instruction conflicts between subtasks
757
+ const instructionConflicts = detectInstructionConflicts(
758
+ validated.subtasks,
759
+ );
760
+
761
+ return JSON.stringify(
762
+ {
763
+ valid: true,
764
+ bead_tree: validated,
765
+ stats: {
766
+ subtask_count: validated.subtasks.length,
767
+ total_files: allFiles.size,
768
+ total_complexity: validated.subtasks.reduce(
769
+ (sum, s) => sum + s.estimated_complexity,
770
+ 0,
771
+ ),
772
+ },
773
+ // Include conflicts as warnings (not blocking)
774
+ warnings:
775
+ instructionConflicts.length > 0
776
+ ? {
777
+ instruction_conflicts: instructionConflicts,
778
+ hint: "Review these potential conflicts between subtask instructions",
779
+ }
780
+ : undefined,
781
+ },
782
+ null,
783
+ 2,
784
+ );
785
+ } catch (error) {
786
+ if (error instanceof z.ZodError) {
787
+ return JSON.stringify(
788
+ {
789
+ valid: false,
790
+ error: "Schema validation failed",
791
+ details: error.issues,
792
+ },
793
+ null,
794
+ 2,
795
+ );
796
+ }
797
+ if (error instanceof SyntaxError) {
798
+ return JSON.stringify(
799
+ {
800
+ valid: false,
801
+ error: "Invalid JSON",
802
+ details: error.message,
803
+ },
804
+ null,
805
+ 2,
806
+ );
807
+ }
808
+ throw error;
809
+ }
810
+ },
811
+ });
812
+
813
+ /**
814
+ * Get status of a swarm by epic ID
815
+ *
816
+ * Requires project_key to query Agent Mail for message counts.
817
+ */
818
+ export const swarm_status = tool({
819
+ description: "Get status of a swarm by epic ID",
820
+ args: {
821
+ epic_id: tool.schema.string().describe("Epic bead ID (e.g., bd-abc123)"),
822
+ project_key: tool.schema
823
+ .string()
824
+ .describe("Project path (for Agent Mail queries)"),
825
+ },
826
+ async execute(args) {
827
+ // Query subtasks from beads
828
+ const subtasks = await queryEpicSubtasks(args.epic_id);
829
+
830
+ // Count statuses
831
+ const statusCounts = {
832
+ running: 0,
833
+ completed: 0,
834
+ failed: 0,
835
+ blocked: 0,
836
+ };
837
+
838
+ const agents: SpawnedAgent[] = [];
839
+
840
+ for (const bead of subtasks) {
841
+ // Map bead status to agent status
842
+ let agentStatus: SpawnedAgent["status"] = "pending";
843
+ switch (bead.status) {
844
+ case "in_progress":
845
+ agentStatus = "running";
846
+ statusCounts.running++;
847
+ break;
848
+ case "closed":
849
+ agentStatus = "completed";
850
+ statusCounts.completed++;
851
+ break;
852
+ case "blocked":
853
+ agentStatus = "pending"; // Blocked treated as pending for swarm
854
+ statusCounts.blocked++;
855
+ break;
856
+ default:
857
+ // open = pending
858
+ break;
859
+ }
860
+
861
+ agents.push({
862
+ bead_id: bead.id,
863
+ agent_name: "", // We don't track this in beads
864
+ status: agentStatus,
865
+ files: [], // Would need to parse from description
866
+ });
867
+ }
868
+
869
+ // Query Agent Mail for message activity
870
+ const messageCount = await querySwarmMessages(
871
+ args.project_key,
872
+ args.epic_id,
873
+ );
874
+
875
+ const status: SwarmStatus = {
876
+ epic_id: args.epic_id,
877
+ total_agents: subtasks.length,
878
+ running: statusCounts.running,
879
+ completed: statusCounts.completed,
880
+ failed: statusCounts.failed,
881
+ blocked: statusCounts.blocked,
882
+ agents,
883
+ last_update: new Date().toISOString(),
884
+ };
885
+
886
+ // Validate and return
887
+ const validated = SwarmStatusSchema.parse(status);
888
+
889
+ return JSON.stringify(
890
+ {
891
+ ...validated,
892
+ message_count: messageCount,
893
+ progress_percent:
894
+ subtasks.length > 0
895
+ ? Math.round((statusCounts.completed / subtasks.length) * 100)
896
+ : 0,
897
+ },
898
+ null,
899
+ 2,
900
+ );
901
+ },
902
+ });
903
+
904
+ /**
905
+ * Report progress on a subtask
906
+ *
907
+ * Takes explicit agent identity since tools don't have persistent state.
908
+ */
909
+ export const swarm_progress = tool({
910
+ description: "Report progress on a subtask to coordinator",
911
+ args: {
912
+ project_key: tool.schema.string().describe("Project path"),
913
+ agent_name: tool.schema.string().describe("Your Agent Mail name"),
914
+ bead_id: tool.schema.string().describe("Subtask bead ID"),
915
+ status: tool.schema
916
+ .enum(["in_progress", "blocked", "completed", "failed"])
917
+ .describe("Current status"),
918
+ message: tool.schema
919
+ .string()
920
+ .optional()
921
+ .describe("Progress message or blockers"),
922
+ progress_percent: tool.schema
923
+ .number()
924
+ .min(0)
925
+ .max(100)
926
+ .optional()
927
+ .describe("Completion percentage"),
928
+ files_touched: tool.schema
929
+ .array(tool.schema.string())
930
+ .optional()
931
+ .describe("Files modified so far"),
932
+ },
933
+ async execute(args) {
934
+ // Build progress report
935
+ const progress: AgentProgress = {
936
+ bead_id: args.bead_id,
937
+ agent_name: args.agent_name,
938
+ status: args.status,
939
+ progress_percent: args.progress_percent,
940
+ message: args.message,
941
+ files_touched: args.files_touched,
942
+ timestamp: new Date().toISOString(),
943
+ };
944
+
945
+ // Validate
946
+ const validated = AgentProgressSchema.parse(progress);
947
+
948
+ // Update bead status if needed
949
+ if (args.status === "blocked" || args.status === "in_progress") {
950
+ const beadStatus = args.status === "blocked" ? "blocked" : "in_progress";
951
+ await Bun.$`bd update ${args.bead_id} --status ${beadStatus} --json`
952
+ .quiet()
953
+ .nothrow();
954
+ }
955
+
956
+ // Extract epic ID from bead ID (e.g., bd-abc123.1 -> bd-abc123)
957
+ const epicId = args.bead_id.includes(".")
958
+ ? args.bead_id.split(".")[0]
959
+ : args.bead_id;
960
+
961
+ // Send progress message to thread
962
+ await mcpCall("send_message", {
963
+ project_key: args.project_key,
964
+ sender_name: args.agent_name,
965
+ to: [], // Coordinator will pick it up from thread
966
+ subject: `Progress: ${args.bead_id} - ${args.status}`,
967
+ body_md: formatProgressMessage(validated),
968
+ thread_id: epicId,
969
+ importance: args.status === "blocked" ? "high" : "normal",
970
+ });
971
+
972
+ return `Progress reported: ${args.status}${args.progress_percent !== undefined ? ` (${args.progress_percent}%)` : ""}`;
973
+ },
974
+ });
975
+
976
+ /**
977
+ * UBS scan result schema
978
+ */
979
+ interface UbsScanResult {
980
+ exitCode: number;
981
+ bugs: Array<{
982
+ file: string;
983
+ line: number;
984
+ severity: string;
985
+ message: string;
986
+ category: string;
987
+ }>;
988
+ summary: {
989
+ total: number;
990
+ critical: number;
991
+ high: number;
992
+ medium: number;
993
+ low: number;
994
+ };
995
+ }
996
+
997
+ /**
998
+ * Run UBS scan on files before completion
999
+ *
1000
+ * @param files - Files to scan
1001
+ * @returns Scan result or null if UBS not available
1002
+ */
1003
+ async function runUbsScan(files: string[]): Promise<UbsScanResult | null> {
1004
+ if (files.length === 0) {
1005
+ return null;
1006
+ }
1007
+
1008
+ try {
1009
+ // Run UBS scan with JSON output
1010
+ const result = await Bun.$`ubs scan ${files.join(" ")} --json`
1011
+ .quiet()
1012
+ .nothrow();
1013
+
1014
+ if (result.exitCode === 127) {
1015
+ // UBS not installed
1016
+ return null;
1017
+ }
1018
+
1019
+ const output = result.stdout.toString();
1020
+ if (!output.trim()) {
1021
+ return {
1022
+ exitCode: result.exitCode,
1023
+ bugs: [],
1024
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
1025
+ };
1026
+ }
1027
+
1028
+ try {
1029
+ const parsed = JSON.parse(output);
1030
+ return {
1031
+ exitCode: result.exitCode,
1032
+ bugs: parsed.bugs || [],
1033
+ summary: parsed.summary || {
1034
+ total: 0,
1035
+ critical: 0,
1036
+ high: 0,
1037
+ medium: 0,
1038
+ low: 0,
1039
+ },
1040
+ };
1041
+ } catch {
1042
+ // UBS output wasn't JSON, return basic result
1043
+ return {
1044
+ exitCode: result.exitCode,
1045
+ bugs: [],
1046
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
1047
+ };
1048
+ }
1049
+ } catch {
1050
+ return null;
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Mark a subtask as complete
1056
+ *
1057
+ * Closes bead, releases reservations, notifies coordinator.
1058
+ * Optionally runs UBS scan on modified files before completion.
1059
+ */
1060
+ export const swarm_complete = tool({
1061
+ description:
1062
+ "Mark subtask complete, release reservations, notify coordinator. Runs UBS bug scan if files_touched provided.",
1063
+ args: {
1064
+ project_key: tool.schema.string().describe("Project path"),
1065
+ agent_name: tool.schema.string().describe("Your Agent Mail name"),
1066
+ bead_id: tool.schema.string().describe("Subtask bead ID"),
1067
+ summary: tool.schema.string().describe("Brief summary of work done"),
1068
+ evaluation: tool.schema
1069
+ .string()
1070
+ .optional()
1071
+ .describe("Self-evaluation JSON (Evaluation schema)"),
1072
+ files_touched: tool.schema
1073
+ .array(tool.schema.string())
1074
+ .optional()
1075
+ .describe("Files modified - will be scanned by UBS for bugs"),
1076
+ skip_ubs_scan: tool.schema
1077
+ .boolean()
1078
+ .optional()
1079
+ .describe("Skip UBS bug scan (default: false)"),
1080
+ },
1081
+ async execute(args) {
1082
+ // Run UBS scan on modified files if provided
1083
+ let ubsResult: UbsScanResult | null = null;
1084
+ if (
1085
+ args.files_touched &&
1086
+ args.files_touched.length > 0 &&
1087
+ !args.skip_ubs_scan
1088
+ ) {
1089
+ ubsResult = await runUbsScan(args.files_touched);
1090
+
1091
+ // Block completion if critical bugs found
1092
+ if (ubsResult && ubsResult.summary.critical > 0) {
1093
+ return JSON.stringify(
1094
+ {
1095
+ success: false,
1096
+ error: "UBS found critical bugs - fix before completing",
1097
+ ubs_scan: {
1098
+ critical_count: ubsResult.summary.critical,
1099
+ bugs: ubsResult.bugs.filter((b) => b.severity === "critical"),
1100
+ },
1101
+ hint: "Fix the critical bugs and try again, or use skip_ubs_scan=true to bypass",
1102
+ },
1103
+ null,
1104
+ 2,
1105
+ );
1106
+ }
1107
+ }
1108
+
1109
+ // Parse and validate evaluation if provided
1110
+ let parsedEvaluation: Evaluation | undefined;
1111
+ if (args.evaluation) {
1112
+ try {
1113
+ parsedEvaluation = EvaluationSchema.parse(JSON.parse(args.evaluation));
1114
+ } catch (error) {
1115
+ return JSON.stringify(
1116
+ {
1117
+ success: false,
1118
+ error: "Invalid evaluation format",
1119
+ details: error instanceof z.ZodError ? error.issues : String(error),
1120
+ },
1121
+ null,
1122
+ 2,
1123
+ );
1124
+ }
1125
+
1126
+ // If evaluation failed, don't complete
1127
+ if (!parsedEvaluation.passed) {
1128
+ return JSON.stringify(
1129
+ {
1130
+ success: false,
1131
+ error: "Self-evaluation failed",
1132
+ retry_suggestion: parsedEvaluation.retry_suggestion,
1133
+ feedback: parsedEvaluation.overall_feedback,
1134
+ },
1135
+ null,
1136
+ 2,
1137
+ );
1138
+ }
1139
+ }
1140
+
1141
+ // Close the bead
1142
+ const closeResult =
1143
+ await Bun.$`bd close ${args.bead_id} --reason ${args.summary} --json`
1144
+ .quiet()
1145
+ .nothrow();
1146
+
1147
+ if (closeResult.exitCode !== 0) {
1148
+ throw new SwarmError(
1149
+ `Failed to close bead: ${closeResult.stderr.toString()}`,
1150
+ "complete",
1151
+ );
1152
+ }
1153
+
1154
+ // Release file reservations for this agent
1155
+ await mcpCall("release_file_reservations", {
1156
+ project_key: args.project_key,
1157
+ agent_name: args.agent_name,
1158
+ });
1159
+
1160
+ // Extract epic ID
1161
+ const epicId = args.bead_id.includes(".")
1162
+ ? args.bead_id.split(".")[0]
1163
+ : args.bead_id;
1164
+
1165
+ // Send completion message
1166
+ const completionBody = [
1167
+ `## Subtask Complete: ${args.bead_id}`,
1168
+ "",
1169
+ `**Summary**: ${args.summary}`,
1170
+ "",
1171
+ parsedEvaluation
1172
+ ? `**Self-Evaluation**: ${parsedEvaluation.passed ? "PASSED" : "FAILED"}`
1173
+ : "",
1174
+ parsedEvaluation?.overall_feedback
1175
+ ? `**Feedback**: ${parsedEvaluation.overall_feedback}`
1176
+ : "",
1177
+ ]
1178
+ .filter(Boolean)
1179
+ .join("\n");
1180
+
1181
+ await mcpCall("send_message", {
1182
+ project_key: args.project_key,
1183
+ sender_name: args.agent_name,
1184
+ to: [], // Thread broadcast
1185
+ subject: `Complete: ${args.bead_id}`,
1186
+ body_md: completionBody,
1187
+ thread_id: epicId,
1188
+ importance: "normal",
1189
+ });
1190
+
1191
+ return JSON.stringify(
1192
+ {
1193
+ success: true,
1194
+ bead_id: args.bead_id,
1195
+ closed: true,
1196
+ reservations_released: true,
1197
+ message_sent: true,
1198
+ ubs_scan: ubsResult
1199
+ ? {
1200
+ ran: true,
1201
+ bugs_found: ubsResult.summary.total,
1202
+ summary: ubsResult.summary,
1203
+ warnings: ubsResult.bugs.filter((b) => b.severity !== "critical"),
1204
+ }
1205
+ : {
1206
+ ran: false,
1207
+ reason: args.skip_ubs_scan
1208
+ ? "skipped"
1209
+ : "no files or ubs unavailable",
1210
+ },
1211
+ },
1212
+ null,
1213
+ 2,
1214
+ );
1215
+ },
1216
+ });
1217
+
1218
+ /**
1219
+ * Record outcome signals from a completed subtask
1220
+ *
1221
+ * Tracks implicit feedback (duration, errors, retries) to score
1222
+ * decomposition quality over time. This data feeds into criterion
1223
+ * weight calculations.
1224
+ *
1225
+ * @see src/learning.ts for scoring logic
1226
+ */
1227
+ export const swarm_record_outcome = tool({
1228
+ description:
1229
+ "Record subtask outcome for implicit feedback scoring. Tracks duration, errors, retries to learn decomposition quality.",
1230
+ args: {
1231
+ bead_id: tool.schema.string().describe("Subtask bead ID"),
1232
+ duration_ms: tool.schema
1233
+ .number()
1234
+ .int()
1235
+ .min(0)
1236
+ .describe("Duration in milliseconds"),
1237
+ error_count: tool.schema
1238
+ .number()
1239
+ .int()
1240
+ .min(0)
1241
+ .default(0)
1242
+ .describe("Number of errors encountered"),
1243
+ retry_count: tool.schema
1244
+ .number()
1245
+ .int()
1246
+ .min(0)
1247
+ .default(0)
1248
+ .describe("Number of retry attempts"),
1249
+ success: tool.schema.boolean().describe("Whether the subtask succeeded"),
1250
+ files_touched: tool.schema
1251
+ .array(tool.schema.string())
1252
+ .optional()
1253
+ .describe("Files that were modified"),
1254
+ criteria: tool.schema
1255
+ .array(tool.schema.string())
1256
+ .optional()
1257
+ .describe(
1258
+ "Criteria to generate feedback for (default: all default criteria)",
1259
+ ),
1260
+ },
1261
+ async execute(args) {
1262
+ // Build outcome signals
1263
+ const signals: OutcomeSignals = {
1264
+ bead_id: args.bead_id,
1265
+ duration_ms: args.duration_ms,
1266
+ error_count: args.error_count ?? 0,
1267
+ retry_count: args.retry_count ?? 0,
1268
+ success: args.success,
1269
+ files_touched: args.files_touched ?? [],
1270
+ timestamp: new Date().toISOString(),
1271
+ };
1272
+
1273
+ // Validate signals
1274
+ const validated = OutcomeSignalsSchema.parse(signals);
1275
+
1276
+ // Score the outcome
1277
+ const scored: ScoredOutcome = scoreImplicitFeedback(
1278
+ validated,
1279
+ DEFAULT_LEARNING_CONFIG,
1280
+ );
1281
+
1282
+ // Generate feedback events for each criterion
1283
+ const criteriaToScore = args.criteria ?? [
1284
+ "type_safe",
1285
+ "no_bugs",
1286
+ "patterns",
1287
+ "readable",
1288
+ ];
1289
+ const feedbackEvents: FeedbackEvent[] = criteriaToScore.map((criterion) =>
1290
+ outcomeToFeedback(scored, criterion),
1291
+ );
1292
+
1293
+ return JSON.stringify(
1294
+ {
1295
+ success: true,
1296
+ outcome: {
1297
+ signals: validated,
1298
+ scored: {
1299
+ type: scored.type,
1300
+ decayed_value: scored.decayed_value,
1301
+ reasoning: scored.reasoning,
1302
+ },
1303
+ },
1304
+ feedback_events: feedbackEvents,
1305
+ summary: {
1306
+ feedback_type: scored.type,
1307
+ duration_seconds: Math.round(args.duration_ms / 1000),
1308
+ error_count: args.error_count ?? 0,
1309
+ retry_count: args.retry_count ?? 0,
1310
+ success: args.success,
1311
+ },
1312
+ note: "Feedback events should be stored for criterion weight calculation. Use learning.ts functions to apply weights.",
1313
+ },
1314
+ null,
1315
+ 2,
1316
+ );
1317
+ },
1318
+ });
1319
+
1320
+ /**
1321
+ * Generate subtask prompt for a spawned agent
1322
+ */
1323
+ export const swarm_subtask_prompt = tool({
1324
+ description: "Generate the prompt for a spawned subtask agent",
1325
+ args: {
1326
+ agent_name: tool.schema.string().describe("Agent Mail name for the agent"),
1327
+ bead_id: tool.schema.string().describe("Subtask bead ID"),
1328
+ epic_id: tool.schema.string().describe("Epic bead ID"),
1329
+ subtask_title: tool.schema.string().describe("Subtask title"),
1330
+ subtask_description: tool.schema
1331
+ .string()
1332
+ .optional()
1333
+ .describe("Detailed subtask instructions"),
1334
+ files: tool.schema
1335
+ .array(tool.schema.string())
1336
+ .describe("Files assigned to this subtask"),
1337
+ shared_context: tool.schema
1338
+ .string()
1339
+ .optional()
1340
+ .describe("Context shared across all agents"),
1341
+ },
1342
+ async execute(args) {
1343
+ const prompt = formatSubtaskPrompt({
1344
+ agent_name: args.agent_name,
1345
+ bead_id: args.bead_id,
1346
+ epic_id: args.epic_id,
1347
+ subtask_title: args.subtask_title,
1348
+ subtask_description: args.subtask_description || "",
1349
+ files: args.files,
1350
+ shared_context: args.shared_context,
1351
+ });
1352
+
1353
+ return prompt;
1354
+ },
1355
+ });
1356
+
1357
+ /**
1358
+ * Generate self-evaluation prompt
1359
+ */
1360
+ export const swarm_evaluation_prompt = tool({
1361
+ description: "Generate self-evaluation prompt for a completed subtask",
1362
+ args: {
1363
+ bead_id: tool.schema.string().describe("Subtask bead ID"),
1364
+ subtask_title: tool.schema.string().describe("Subtask title"),
1365
+ files_touched: tool.schema
1366
+ .array(tool.schema.string())
1367
+ .describe("Files that were modified"),
1368
+ },
1369
+ async execute(args) {
1370
+ const prompt = formatEvaluationPrompt({
1371
+ bead_id: args.bead_id,
1372
+ subtask_title: args.subtask_title,
1373
+ files_touched: args.files_touched,
1374
+ });
1375
+
1376
+ return JSON.stringify(
1377
+ {
1378
+ prompt,
1379
+ expected_schema: "Evaluation",
1380
+ schema_hint: {
1381
+ passed: "boolean",
1382
+ criteria: {
1383
+ type_safe: { passed: "boolean", feedback: "string" },
1384
+ no_bugs: { passed: "boolean", feedback: "string" },
1385
+ patterns: { passed: "boolean", feedback: "string" },
1386
+ readable: { passed: "boolean", feedback: "string" },
1387
+ },
1388
+ overall_feedback: "string",
1389
+ retry_suggestion: "string | null",
1390
+ },
1391
+ },
1392
+ null,
1393
+ 2,
1394
+ );
1395
+ },
1396
+ });
1397
+
1398
+ // ============================================================================
1399
+ // Export all tools
1400
+ // ============================================================================
1401
+
1402
+ export const swarmTools = {
1403
+ swarm_decompose: swarm_decompose,
1404
+ swarm_validate_decomposition: swarm_validate_decomposition,
1405
+ swarm_status: swarm_status,
1406
+ swarm_progress: swarm_progress,
1407
+ swarm_complete: swarm_complete,
1408
+ swarm_record_outcome: swarm_record_outcome,
1409
+ swarm_subtask_prompt: swarm_subtask_prompt,
1410
+ swarm_evaluation_prompt: swarm_evaluation_prompt,
1411
+ };