opencode-swarm-plugin 0.29.0 → 0.30.2

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 (42) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +3 -6
  4. package/bin/swarm.test.ts +163 -0
  5. package/bin/swarm.ts +304 -72
  6. package/dist/hive.d.ts.map +1 -1
  7. package/dist/index.d.ts +94 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +18825 -3469
  10. package/dist/memory-tools.d.ts +209 -0
  11. package/dist/memory-tools.d.ts.map +1 -0
  12. package/dist/memory.d.ts +124 -0
  13. package/dist/memory.d.ts.map +1 -0
  14. package/dist/plugin.js +18775 -3430
  15. package/dist/schemas/index.d.ts +7 -0
  16. package/dist/schemas/index.d.ts.map +1 -1
  17. package/dist/schemas/worker-handoff.d.ts +78 -0
  18. package/dist/schemas/worker-handoff.d.ts.map +1 -0
  19. package/dist/swarm-orchestrate.d.ts +50 -0
  20. package/dist/swarm-orchestrate.d.ts.map +1 -1
  21. package/dist/swarm-prompts.d.ts +1 -1
  22. package/dist/swarm-prompts.d.ts.map +1 -1
  23. package/dist/swarm-review.d.ts +4 -0
  24. package/dist/swarm-review.d.ts.map +1 -1
  25. package/docs/planning/ADR-008-worker-handoff-protocol.md +293 -0
  26. package/examples/plugin-wrapper-template.ts +157 -28
  27. package/package.json +3 -1
  28. package/src/hive.integration.test.ts +114 -0
  29. package/src/hive.ts +33 -22
  30. package/src/index.ts +41 -8
  31. package/src/memory-tools.test.ts +111 -0
  32. package/src/memory-tools.ts +273 -0
  33. package/src/memory.integration.test.ts +266 -0
  34. package/src/memory.test.ts +334 -0
  35. package/src/memory.ts +441 -0
  36. package/src/schemas/index.ts +18 -0
  37. package/src/schemas/worker-handoff.test.ts +271 -0
  38. package/src/schemas/worker-handoff.ts +131 -0
  39. package/src/swarm-orchestrate.ts +262 -24
  40. package/src/swarm-prompts.ts +48 -5
  41. package/src/swarm-review.ts +7 -0
  42. package/src/swarm.integration.test.ts +386 -9
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { tool } from "@opencode-ai/plugin";
23
23
  import { z } from "zod";
24
+ import { minimatch } from "minimatch";
24
25
  import {
25
26
  type AgentProgress,
26
27
  AgentProgressSchema,
@@ -32,6 +33,10 @@ import {
32
33
  type SwarmStatus,
33
34
  SwarmStatusSchema,
34
35
  } from "./schemas";
36
+ import {
37
+ type WorkerHandoff,
38
+ WorkerHandoffSchema,
39
+ } from "./schemas/worker-handoff";
35
40
  import {
36
41
  getSwarmInbox,
37
42
  releaseSwarmFiles,
@@ -82,6 +87,182 @@ import {
82
87
  // Helper Functions
83
88
  // ============================================================================
84
89
 
90
+ /**
91
+ * Generate a WorkerHandoff object from subtask parameters
92
+ *
93
+ * Creates a machine-readable contract that replaces prose instructions in SUBTASK_PROMPT_V2.
94
+ * Workers receive typed handoffs with explicit files, criteria, and escalation paths.
95
+ *
96
+ * @param params - Subtask parameters
97
+ * @returns WorkerHandoff object validated against schema
98
+ */
99
+ export function generateWorkerHandoff(params: {
100
+ task_id: string;
101
+ files_owned: string[];
102
+ files_readonly?: string[];
103
+ dependencies_completed?: string[];
104
+ success_criteria?: string[];
105
+ epic_summary: string;
106
+ your_role: string;
107
+ what_others_did?: string;
108
+ what_comes_next?: string;
109
+ }): WorkerHandoff {
110
+ const handoff: WorkerHandoff = {
111
+ contract: {
112
+ task_id: params.task_id,
113
+ files_owned: params.files_owned,
114
+ files_readonly: params.files_readonly || [],
115
+ dependencies_completed: params.dependencies_completed || [],
116
+ success_criteria: params.success_criteria || [
117
+ "All files compile without errors",
118
+ "Tests pass for modified code",
119
+ "Code follows project patterns",
120
+ ],
121
+ },
122
+ context: {
123
+ epic_summary: params.epic_summary,
124
+ your_role: params.your_role,
125
+ what_others_did: params.what_others_did || "",
126
+ what_comes_next: params.what_comes_next || "",
127
+ },
128
+ escalation: {
129
+ blocked_contact: "coordinator",
130
+ scope_change_protocol:
131
+ "Send swarmmail_send(to=['coordinator'], subject='Scope change request: <task_id>', importance='high') and wait for approval before expanding beyond files_owned",
132
+ },
133
+ };
134
+
135
+ // Validate against schema
136
+ return WorkerHandoffSchema.parse(handoff);
137
+ }
138
+
139
+ /**
140
+ * Validate that files_touched is a subset of files_owned (supports globs)
141
+ *
142
+ * Checks contract compliance - workers should only modify files they own.
143
+ * Glob patterns in files_owned are matched against files_touched paths.
144
+ *
145
+ * @param files_touched - Actual files modified by the worker
146
+ * @param files_owned - Files the worker is allowed to modify (may include globs)
147
+ * @returns Validation result with violations list
148
+ *
149
+ * @example
150
+ * ```typescript
151
+ * // Exact match - passes
152
+ * validateContract(["src/a.ts"], ["src/a.ts", "src/b.ts"])
153
+ * // => { valid: true, violations: [] }
154
+ *
155
+ * // Glob match - passes
156
+ * validateContract(["src/auth/service.ts"], ["src/auth/**"])
157
+ * // => { valid: true, violations: [] }
158
+ *
159
+ * // Violation - fails
160
+ * validateContract(["src/other.ts"], ["src/auth/**"])
161
+ * // => { valid: false, violations: ["src/other.ts"] }
162
+ * ```
163
+ */
164
+ export function validateContract(
165
+ files_touched: string[],
166
+ files_owned: string[]
167
+ ): { valid: boolean; violations: string[] } {
168
+ // Empty files_touched is valid (read-only work)
169
+ if (files_touched.length === 0) {
170
+ return { valid: true, violations: [] };
171
+ }
172
+
173
+ const violations: string[] = [];
174
+
175
+ for (const touchedFile of files_touched) {
176
+ let matched = false;
177
+
178
+ for (const ownedPattern of files_owned) {
179
+ // Check if pattern is a glob or exact match
180
+ if (ownedPattern.includes("*") || ownedPattern.includes("?")) {
181
+ // Glob pattern - use minimatch
182
+ if (minimatch(touchedFile, ownedPattern)) {
183
+ matched = true;
184
+ break;
185
+ }
186
+ } else {
187
+ // Exact match
188
+ if (touchedFile === ownedPattern) {
189
+ matched = true;
190
+ break;
191
+ }
192
+ }
193
+ }
194
+
195
+ if (!matched) {
196
+ violations.push(touchedFile);
197
+ }
198
+ }
199
+
200
+ return {
201
+ valid: violations.length === 0,
202
+ violations,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Get files_owned for a subtask from DecompositionGeneratedEvent
208
+ *
209
+ * Queries the event log for the decomposition that created this epic,
210
+ * then extracts the files array for the matching subtask.
211
+ *
212
+ * @param projectKey - Project path
213
+ * @param epicId - Epic ID
214
+ * @param subtaskId - Subtask cell ID
215
+ * @returns Array of file patterns this subtask owns, or null if not found
216
+ */
217
+ async function getSubtaskFilesOwned(
218
+ projectKey: string,
219
+ epicId: string,
220
+ subtaskId: string
221
+ ): Promise<string[] | null> {
222
+ try {
223
+ // Import readEvents from swarm-mail
224
+ const { readEvents } = await import("swarm-mail");
225
+
226
+ // Query for decomposition_generated events for this epic
227
+ const events = await readEvents({
228
+ projectKey,
229
+ types: ["decomposition_generated"],
230
+ }, projectKey);
231
+
232
+ // Find the event for this epic
233
+ const decompositionEvent = events.find((e: any) =>
234
+ e.type === "decomposition_generated" && e.epic_id === epicId
235
+ );
236
+
237
+ if (!decompositionEvent) {
238
+ console.warn(`[swarm_complete] No decomposition event found for epic ${epicId}`);
239
+ return null;
240
+ }
241
+
242
+ // Extract subtask index from subtask ID (e.g., "bd-abc123.0" -> 0)
243
+ // Subtask IDs follow pattern: epicId.index
244
+ const subtaskMatch = subtaskId.match(/\.(\d+)$/);
245
+ if (!subtaskMatch) {
246
+ console.warn(`[swarm_complete] Could not parse subtask index from ${subtaskId}`);
247
+ return null;
248
+ }
249
+
250
+ const subtaskIndex = parseInt(subtaskMatch[1], 10);
251
+ const subtasks = (decompositionEvent as any).subtasks || [];
252
+
253
+ if (subtaskIndex >= subtasks.length) {
254
+ console.warn(`[swarm_complete] Subtask index ${subtaskIndex} out of range (${subtasks.length} subtasks)`);
255
+ return null;
256
+ }
257
+
258
+ const subtask = subtasks[subtaskIndex];
259
+ return subtask.files || [];
260
+ } catch (error) {
261
+ console.error(`[swarm_complete] Failed to query subtask files:`, error);
262
+ return null;
263
+ }
264
+ }
265
+
85
266
  /**
86
267
  * Query beads for subtasks of an epic using HiveAdapter (not bd CLI)
87
268
  */
@@ -1109,17 +1290,16 @@ export const swarm_complete = tool({
1109
1290
  if (!reviewStatusResult.reviewed) {
1110
1291
  return JSON.stringify(
1111
1292
  {
1112
- success: false,
1113
- error: "Review required before completion",
1293
+ success: true,
1294
+ status: "pending_review",
1114
1295
  review_status: reviewStatusResult,
1115
- hint: `This task requires coordinator review before completion.
1116
-
1117
- **Next steps:**
1118
- 1. Request review with swarm_review(project_key="${args.project_key}", epic_id="${epicId}", task_id="${args.bead_id}", files_touched=[...])
1119
- 2. Wait for coordinator to review and approve with swarm_review_feedback
1120
- 3. Once approved, call swarm_complete again
1121
-
1122
- Or use skip_review=true to bypass (not recommended for production work).`,
1296
+ message: "Task completed but awaiting coordinator review before finalization.",
1297
+ next_steps: [
1298
+ `Request review with swarm_review(project_key="${args.project_key}", epic_id="${epicId}", task_id="${args.bead_id}", files_touched=[...])`,
1299
+ "Wait for coordinator to review and approve with swarm_review_feedback",
1300
+ "Once approved, call swarm_complete again to finalize",
1301
+ "Or use skip_review=true to bypass (not recommended for production work)",
1302
+ ],
1123
1303
  },
1124
1304
  null,
1125
1305
  2,
@@ -1129,15 +1309,15 @@ Or use skip_review=true to bypass (not recommended for production work).`,
1129
1309
  // Review was attempted but not approved
1130
1310
  return JSON.stringify(
1131
1311
  {
1132
- success: false,
1133
- error: "Review not approved",
1312
+ success: true,
1313
+ status: "needs_changes",
1134
1314
  review_status: reviewStatusResult,
1135
- hint: `Task was reviewed but not approved. ${reviewStatusResult.remaining_attempts} attempt(s) remaining.
1136
-
1137
- **Next steps:**
1138
- 1. Address the feedback from the reviewer
1139
- 2. Request another review with swarm_review
1140
- 3. Once approved, call swarm_complete again`,
1315
+ message: `Task reviewed but changes requested. ${reviewStatusResult.remaining_attempts} attempt(s) remaining.`,
1316
+ next_steps: [
1317
+ "Address the feedback from the reviewer",
1318
+ `Request another review with swarm_review(project_key="${args.project_key}", epic_id="${epicId}", task_id="${args.bead_id}", files_touched=[...])`,
1319
+ "Once approved, call swarm_complete again to finalize",
1320
+ ],
1141
1321
  },
1142
1322
  null,
1143
1323
  2,
@@ -1147,15 +1327,14 @@ Or use skip_review=true to bypass (not recommended for production work).`,
1147
1327
 
1148
1328
  try {
1149
1329
  // Validate bead_id exists and is not already closed (EARLY validation)
1150
- const projectKey = args.project_key
1151
- .replace(/\//g, "-")
1152
- .replace(/\\/g, "-");
1330
+ // NOTE: Use args.project_key directly - cells are stored with the original path
1331
+ // (e.g., "/Users/joel/Code/project"), not a mangled version.
1153
1332
 
1154
1333
  // Use HiveAdapter for validation (not bd CLI)
1155
1334
  const adapter = await getHiveAdapter(args.project_key);
1156
1335
 
1157
1336
  // 1. Check if bead exists
1158
- const cell = await adapter.getCell(projectKey, args.bead_id);
1337
+ const cell = await adapter.getCell(args.project_key, args.bead_id);
1159
1338
  if (!cell) {
1160
1339
  return JSON.stringify({
1161
1340
  success: false,
@@ -1180,14 +1359,14 @@ Or use skip_review=true to bypass (not recommended for production work).`,
1180
1359
 
1181
1360
  try {
1182
1361
  const agent = await getAgent(
1183
- projectKey,
1362
+ args.project_key,
1184
1363
  args.agent_name,
1185
1364
  args.project_key,
1186
1365
  );
1187
1366
  agentRegistered = agent !== null;
1188
1367
 
1189
1368
  if (!agentRegistered) {
1190
- registrationWarning = `⚠️ WARNING: Agent '${args.agent_name}' was NOT registered in swarm-mail for project '${projectKey}'.
1369
+ registrationWarning = `⚠️ WARNING: Agent '${args.agent_name}' was NOT registered in swarm-mail for project '${args.project_key}'.
1191
1370
 
1192
1371
  This usually means you skipped the MANDATORY swarmmail_init step.
1193
1372
 
@@ -1286,6 +1465,48 @@ Continuing with completion, but this should be fixed for future subtasks.`;
1286
1465
  }
1287
1466
  }
1288
1467
 
1468
+ // Contract Validation - check files_touched against WorkerHandoff contract
1469
+ let contractValidation: { valid: boolean; violations: string[] } | null = null;
1470
+ let contractWarning: string | undefined;
1471
+
1472
+ if (args.files_touched && args.files_touched.length > 0) {
1473
+ // Extract epic ID from subtask ID
1474
+ const isSubtask = args.bead_id.includes(".");
1475
+
1476
+ if (isSubtask) {
1477
+ const epicId = args.bead_id.split(".")[0];
1478
+
1479
+ // Query decomposition event for files_owned
1480
+ const filesOwned = await getSubtaskFilesOwned(
1481
+ args.project_key,
1482
+ epicId,
1483
+ args.bead_id
1484
+ );
1485
+
1486
+ if (filesOwned) {
1487
+ contractValidation = validateContract(args.files_touched, filesOwned);
1488
+
1489
+ if (!contractValidation.valid) {
1490
+ // Contract violation - log warning (don't block completion)
1491
+ contractWarning = `⚠️ CONTRACT VIOLATION: Modified files outside owned scope
1492
+
1493
+ **Files owned**: ${filesOwned.join(", ")}
1494
+ **Files touched**: ${args.files_touched.join(", ")}
1495
+ **Violations**: ${contractValidation.violations.join(", ")}
1496
+
1497
+ This indicates scope creep - the worker modified files they weren't assigned.
1498
+ This will be recorded as a negative learning signal.`;
1499
+
1500
+ console.warn(`[swarm_complete] ${contractWarning}`);
1501
+ } else {
1502
+ console.log(`[swarm_complete] Contract validation passed: all ${args.files_touched.length} files within owned scope`);
1503
+ }
1504
+ } else {
1505
+ console.warn(`[swarm_complete] Could not retrieve files_owned for contract validation - skipping`);
1506
+ }
1507
+ }
1508
+ }
1509
+
1289
1510
  // Parse and validate evaluation if provided
1290
1511
  let parsedEvaluation: Evaluation | undefined;
1291
1512
  if (args.evaluation) {
@@ -1367,6 +1588,8 @@ Continuing with completion, but this should be fixed for future subtasks.`;
1367
1588
  error_count: args.error_count || 0,
1368
1589
  retry_count: args.retry_count || 0,
1369
1590
  success: true,
1591
+ scope_violation: contractValidation ? !contractValidation.valid : undefined,
1592
+ violation_files: contractValidation?.violations,
1370
1593
  });
1371
1594
  await appendEvent(event, args.project_key);
1372
1595
  } catch (error) {
@@ -1544,6 +1767,21 @@ Files touched: ${args.files_touched?.join(", ") || "none recorded"}`,
1544
1767
  ? "Learning automatically stored in semantic-memory"
1545
1768
  : `Failed to store: ${memoryError}. Learning lost unless semantic-memory is available.`,
1546
1769
  },
1770
+ // Contract validation result
1771
+ contract_validation: contractValidation
1772
+ ? {
1773
+ validated: true,
1774
+ passed: contractValidation.valid,
1775
+ violations: contractValidation.violations,
1776
+ warning: contractWarning,
1777
+ note: contractValidation.valid
1778
+ ? "All files within owned scope"
1779
+ : "Scope violation detected - recorded as negative learning signal",
1780
+ }
1781
+ : {
1782
+ validated: false,
1783
+ reason: "No files_owned contract found (non-epic subtask or decomposition event missing)",
1784
+ },
1547
1785
  };
1548
1786
 
1549
1787
  return JSON.stringify(response, null, 2);
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { tool } from "@opencode-ai/plugin";
16
+ import { generateWorkerHandoff } from "./swarm-orchestrate";
16
17
 
17
18
  // ============================================================================
18
19
  // Prompt Templates
@@ -326,10 +327,32 @@ swarmmail_reserve(
326
327
 
327
328
  **Workers reserve their own files.** This prevents edit conflicts with other agents.
328
329
 
329
- ### Step 5: Do the Work
330
- - Read your assigned files
331
- - Implement changes
332
- - Verify (typecheck if applicable)
330
+ ### Step 5: Do the Work (TDD MANDATORY)
331
+
332
+ **Follow RED → GREEN → REFACTOR. No exceptions.**
333
+
334
+ 1. **RED**: Write a failing test that describes the expected behavior
335
+ - Test MUST fail before you write implementation
336
+ - If test passes immediately, your test is wrong
337
+
338
+ 2. **GREEN**: Write minimal code to make the test pass
339
+ - Don't over-engineer - just make it green
340
+ - Hardcode if needed, refactor later
341
+
342
+ 3. **REFACTOR**: Clean up while tests stay green
343
+ - Run tests after every change
344
+ - If tests break, undo and try again
345
+
346
+ \`\`\`bash
347
+ # Run tests continuously
348
+ bun test <your-test-file> --watch
349
+ \`\`\`
350
+
351
+ **Why TDD?**
352
+ - Catches bugs before they exist
353
+ - Documents expected behavior
354
+ - Enables fearless refactoring
355
+ - Proves your code works
333
356
 
334
357
  ### Step 6: Report Progress at Milestones
335
358
  \`\`\`
@@ -591,6 +614,26 @@ export function formatSubtaskPromptV2(params: {
591
614
  }
592
615
  }
593
616
 
617
+ // Generate WorkerHandoff contract (machine-readable section)
618
+ const handoff = generateWorkerHandoff({
619
+ task_id: params.bead_id,
620
+ files_owned: params.files,
621
+ files_readonly: [],
622
+ dependencies_completed: [],
623
+ success_criteria: [
624
+ "All files compile without errors",
625
+ "Tests pass for modified code",
626
+ "Code follows project patterns",
627
+ ],
628
+ epic_summary: params.subtask_description || params.subtask_title,
629
+ your_role: params.subtask_title,
630
+ what_others_did: params.recovery_context?.shared_context || "",
631
+ what_comes_next: "",
632
+ });
633
+
634
+ const handoffJson = JSON.stringify(handoff, null, 2);
635
+ const handoffSection = `\n## WorkerHandoff Contract\n\nThis is your machine-readable contract. The contract IS the instruction.\n\n\`\`\`json\n${handoffJson}\n\`\`\`\n`;
636
+
594
637
  return SUBTASK_PROMPT_V2.replace(/{bead_id}/g, params.bead_id)
595
638
  .replace(/{epic_id}/g, params.epic_id)
596
639
  .replace(/{project_path}/g, params.project_path || "$PWD")
@@ -602,7 +645,7 @@ export function formatSubtaskPromptV2(params: {
602
645
  .replace("{file_list}", fileList)
603
646
  .replace("{shared_context}", params.shared_context || "(none)")
604
647
  .replace("{compressed_context}", compressedSection)
605
- .replace("{error_context}", errorSection + recoverySection);
648
+ .replace("{error_context}", errorSection + recoverySection + handoffSection);
606
649
  }
607
650
 
608
651
  /**
@@ -686,6 +686,13 @@ export function clearReviewStatus(taskId: string): void {
686
686
  clearAttempts(taskId);
687
687
  }
688
688
 
689
+ /**
690
+ * Mark a task as reviewed but not approved (for testing)
691
+ */
692
+ export function markReviewRejected(taskId: string): void {
693
+ reviewStatus.set(taskId, { approved: false, timestamp: Date.now() });
694
+ }
695
+
689
696
  // ============================================================================
690
697
  // Exports
691
698
  // ============================================================================