opencode-swarm-plugin 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.hive/issues.jsonl +4 -4
  2. package/.hive/memories.jsonl +274 -1
  3. package/.turbo/turbo-build.log +4 -4
  4. package/.turbo/turbo-test.log +307 -307
  5. package/CHANGELOG.md +133 -0
  6. package/bin/swarm.ts +234 -179
  7. package/dist/compaction-hook.d.ts +54 -4
  8. package/dist/compaction-hook.d.ts.map +1 -1
  9. package/dist/eval-capture.d.ts +122 -17
  10. package/dist/eval-capture.d.ts.map +1 -1
  11. package/dist/index.d.ts +1 -7
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1278 -619
  14. package/dist/planning-guardrails.d.ts +121 -0
  15. package/dist/planning-guardrails.d.ts.map +1 -1
  16. package/dist/plugin.d.ts +9 -9
  17. package/dist/plugin.d.ts.map +1 -1
  18. package/dist/plugin.js +1283 -329
  19. package/dist/schemas/task.d.ts +0 -1
  20. package/dist/schemas/task.d.ts.map +1 -1
  21. package/dist/swarm-decompose.d.ts +0 -8
  22. package/dist/swarm-decompose.d.ts.map +1 -1
  23. package/dist/swarm-orchestrate.d.ts.map +1 -1
  24. package/dist/swarm-prompts.d.ts +0 -4
  25. package/dist/swarm-prompts.d.ts.map +1 -1
  26. package/dist/swarm-review.d.ts.map +1 -1
  27. package/dist/swarm.d.ts +0 -6
  28. package/dist/swarm.d.ts.map +1 -1
  29. package/evals/README.md +38 -0
  30. package/evals/coordinator-session.eval.ts +154 -0
  31. package/evals/fixtures/coordinator-sessions.ts +328 -0
  32. package/evals/lib/data-loader.ts +69 -0
  33. package/evals/scorers/coordinator-discipline.evalite-test.ts +536 -0
  34. package/evals/scorers/coordinator-discipline.ts +315 -0
  35. package/evals/scorers/index.ts +12 -0
  36. package/examples/plugin-wrapper-template.ts +747 -34
  37. package/package.json +2 -2
  38. package/src/compaction-hook.test.ts +234 -281
  39. package/src/compaction-hook.ts +221 -63
  40. package/src/eval-capture.test.ts +390 -0
  41. package/src/eval-capture.ts +168 -10
  42. package/src/index.ts +89 -2
  43. package/src/learning.integration.test.ts +0 -2
  44. package/src/planning-guardrails.test.ts +387 -2
  45. package/src/planning-guardrails.ts +289 -0
  46. package/src/plugin.ts +10 -10
  47. package/src/schemas/task.ts +0 -1
  48. package/src/swarm-decompose.ts +21 -8
  49. package/src/swarm-orchestrate.ts +44 -0
  50. package/src/swarm-prompts.ts +20 -0
  51. package/src/swarm-review.ts +41 -0
  52. package/src/swarm.integration.test.ts +0 -40
package/src/index.ts CHANGED
@@ -57,7 +57,13 @@ import {
57
57
  import {
58
58
  analyzeTodoWrite,
59
59
  shouldAnalyzeTool,
60
+ detectCoordinatorViolation,
61
+ isInCoordinatorContext,
62
+ getCoordinatorContext,
63
+ setCoordinatorContext,
64
+ clearCoordinatorContext,
60
65
  } from "./planning-guardrails";
66
+ import { createCompactionHook } from "./compaction-hook";
61
67
 
62
68
  /**
63
69
  * OpenCode Swarm Plugin
@@ -77,10 +83,10 @@ import {
77
83
  * @param input - Plugin context from OpenCode
78
84
  * @returns Plugin hooks including tools, events, and tool execution hooks
79
85
  */
80
- export const SwarmPlugin: Plugin = async (
86
+ const SwarmPlugin: Plugin = async (
81
87
  input: PluginInput,
82
88
  ): Promise<Hooks> => {
83
- const { $, directory } = input;
89
+ const { $, directory, client } = input;
84
90
 
85
91
  // Set the working directory for hive commands
86
92
  // This ensures hive operations run in the project directory, not ~/.config/opencode
@@ -189,6 +195,8 @@ export const SwarmPlugin: Plugin = async (
189
195
  *
190
196
  * Warns when agents are about to make planning mistakes:
191
197
  * - Using todowrite for multi-file implementation (should use swarm)
198
+ * - Coordinator editing files directly (should spawn workers)
199
+ * - Coordinator running tests (workers should run tests)
192
200
  */
193
201
  "tool.execute.before": async (input, output) => {
194
202
  const toolName = input.tool;
@@ -200,6 +208,36 @@ export const SwarmPlugin: Plugin = async (
200
208
  console.warn(`[swarm-plugin] ${analysis.warning}`);
201
209
  }
202
210
  }
211
+
212
+ // Check for coordinator violations when in coordinator context
213
+ if (isInCoordinatorContext()) {
214
+ const ctx = getCoordinatorContext();
215
+ const violation = detectCoordinatorViolation({
216
+ sessionId: ctx.sessionId || "unknown",
217
+ epicId: ctx.epicId || "unknown",
218
+ toolName,
219
+ toolArgs: output.args as Record<string, unknown>,
220
+ agentContext: "coordinator",
221
+ });
222
+
223
+ if (violation.isViolation) {
224
+ console.warn(`[swarm-plugin] ${violation.message}`);
225
+ }
226
+ }
227
+
228
+ // Activate coordinator context when swarm tools are used
229
+ if (toolName === "hive_create_epic" || toolName === "swarm_decompose") {
230
+ setCoordinatorContext({
231
+ isCoordinator: true,
232
+ sessionId: input.sessionID,
233
+ });
234
+ }
235
+
236
+ // Capture epic ID when epic is created
237
+ if (toolName === "hive_create_epic" && output.args) {
238
+ const args = output.args as { epic_title?: string };
239
+ // Epic ID will be set after execution in tool.execute.after
240
+ }
203
241
  },
204
242
 
205
243
  /**
@@ -257,10 +295,59 @@ export const SwarmPlugin: Plugin = async (
257
295
  await releaseReservations();
258
296
  }
259
297
 
298
+ // Capture epic ID when epic is created (for coordinator context)
299
+ if (toolName === "hive_create_epic" && output.output) {
300
+ try {
301
+ const result = JSON.parse(output.output);
302
+ if (result.epic?.id) {
303
+ setCoordinatorContext({
304
+ isCoordinator: true,
305
+ epicId: result.epic.id,
306
+ sessionId: input.sessionID,
307
+ });
308
+ }
309
+ } catch {
310
+ // Parsing failed - ignore
311
+ }
312
+ }
313
+
314
+ // Clear coordinator context when epic is closed
315
+ if (toolName === "hive_close" && output.output && isInCoordinatorContext()) {
316
+ const ctx = getCoordinatorContext();
317
+ try {
318
+ // Check if the closed cell is the active epic
319
+ const result = JSON.parse(output.output);
320
+ if (result.id === ctx.epicId) {
321
+ clearCoordinatorContext();
322
+ }
323
+ } catch {
324
+ // Parsing failed - ignore
325
+ }
326
+ }
327
+
260
328
  // Note: hive_sync should be called explicitly at session end
261
329
  // Auto-sync was removed because bd CLI is deprecated
262
330
  // The hive_sync tool handles flushing to JSONL and git commit/push
263
331
  },
332
+
333
+ /**
334
+ * Compaction hook for swarm context preservation
335
+ *
336
+ * When OpenCode compacts session context, this hook injects swarm state
337
+ * to ensure coordinators can resume orchestration seamlessly.
338
+ *
339
+ * Uses SDK client to scan actual session messages for precise swarm state
340
+ * (epic IDs, subtask status, agent names) rather than relying solely on
341
+ * heuristic detection from hive/swarm-mail.
342
+ *
343
+ * Note: This hook is experimental and may not be in the published Hooks type yet.
344
+ */
345
+ "experimental.session.compacting": createCompactionHook(client),
346
+ } as Hooks & {
347
+ "experimental.session.compacting"?: (
348
+ input: { sessionID: string },
349
+ output: { context: string[] },
350
+ ) => Promise<void>;
264
351
  };
265
352
  };
266
353
 
@@ -976,7 +976,6 @@ describe("Swarm Tool Integrations", () => {
976
976
  const result = await swarm_decompose.execute(
977
977
  {
978
978
  task: "Add user authentication",
979
- max_subtasks: 3,
980
979
  query_cass: true,
981
980
  },
982
981
  mockContext,
@@ -992,7 +991,6 @@ describe("Swarm Tool Integrations", () => {
992
991
  const result = await swarm_decompose.execute(
993
992
  {
994
993
  task: "Add user authentication",
995
- max_subtasks: 3,
996
994
  query_cass: false,
997
995
  },
998
996
  mockContext,
@@ -1,5 +1,17 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { analyzeTodoWrite, shouldAnalyzeTool } from "./planning-guardrails";
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import {
3
+ analyzeTodoWrite,
4
+ shouldAnalyzeTool,
5
+ detectCoordinatorViolation,
6
+ setCoordinatorContext,
7
+ getCoordinatorContext,
8
+ clearCoordinatorContext,
9
+ isInCoordinatorContext,
10
+ type ViolationDetectionResult,
11
+ } from "./planning-guardrails";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as os from "os";
3
15
 
4
16
  describe("planning-guardrails", () => {
5
17
  describe("shouldAnalyzeTool", () => {
@@ -103,4 +115,377 @@ describe("planning-guardrails", () => {
103
115
  expect(result.totalCount).toBe(6);
104
116
  });
105
117
  });
118
+
119
+ describe("detectCoordinatorViolation", () => {
120
+ const sessionId = "test-session-123";
121
+ const epicId = "test-epic-456";
122
+
123
+ // Clean up session files after tests
124
+ afterEach(() => {
125
+ const sessionDir = path.join(os.homedir(), ".config", "swarm-tools", "sessions");
126
+ const sessionPath = path.join(sessionDir, `${sessionId}.jsonl`);
127
+ if (fs.existsSync(sessionPath)) {
128
+ fs.unlinkSync(sessionPath);
129
+ }
130
+ });
131
+
132
+ describe("coordinator_edited_file violation", () => {
133
+ it("detects Edit tool call from coordinator", () => {
134
+ const result = detectCoordinatorViolation({
135
+ sessionId,
136
+ epicId,
137
+ toolName: "edit",
138
+ toolArgs: { filePath: "/path/to/file.ts", oldString: "old", newString: "new" },
139
+ agentContext: "coordinator",
140
+ });
141
+
142
+ expect(result.isViolation).toBe(true);
143
+ expect(result.violationType).toBe("coordinator_edited_file");
144
+ expect(result.message).toContain("Coordinators should spawn workers");
145
+ expect(result.payload.tool).toBe("edit");
146
+ expect(result.payload.file).toBe("/path/to/file.ts");
147
+ });
148
+
149
+ it("detects Write tool call from coordinator", () => {
150
+ const result = detectCoordinatorViolation({
151
+ sessionId,
152
+ epicId,
153
+ toolName: "write",
154
+ toolArgs: { filePath: "/path/to/new-file.ts", content: "export const foo = 1;" },
155
+ agentContext: "coordinator",
156
+ });
157
+
158
+ expect(result.isViolation).toBe(true);
159
+ expect(result.violationType).toBe("coordinator_edited_file");
160
+ expect(result.message).toContain("Coordinators should spawn workers");
161
+ expect(result.payload.tool).toBe("write");
162
+ expect(result.payload.file).toBe("/path/to/new-file.ts");
163
+ });
164
+
165
+ it("does not detect edit from worker agent", () => {
166
+ const result = detectCoordinatorViolation({
167
+ sessionId,
168
+ epicId,
169
+ toolName: "edit",
170
+ toolArgs: { filePath: "/path/to/file.ts", oldString: "old", newString: "new" },
171
+ agentContext: "worker",
172
+ });
173
+
174
+ expect(result.isViolation).toBe(false);
175
+ });
176
+
177
+ it("does not detect Read tool (read-only)", () => {
178
+ const result = detectCoordinatorViolation({
179
+ sessionId,
180
+ epicId,
181
+ toolName: "read",
182
+ toolArgs: { filePath: "/path/to/file.ts" },
183
+ agentContext: "coordinator",
184
+ });
185
+
186
+ expect(result.isViolation).toBe(false);
187
+ });
188
+ });
189
+
190
+ describe("coordinator_ran_tests violation", () => {
191
+ it("detects bash test execution from coordinator", () => {
192
+ const result = detectCoordinatorViolation({
193
+ sessionId,
194
+ epicId,
195
+ toolName: "bash",
196
+ toolArgs: { command: "bun test src/module.test.ts" },
197
+ agentContext: "coordinator",
198
+ });
199
+
200
+ expect(result.isViolation).toBe(true);
201
+ expect(result.violationType).toBe("coordinator_ran_tests");
202
+ expect(result.message).toContain("Workers run tests");
203
+ expect(result.payload.command).toContain("bun test");
204
+ });
205
+
206
+ it("detects npm test from coordinator", () => {
207
+ const result = detectCoordinatorViolation({
208
+ sessionId,
209
+ epicId,
210
+ toolName: "bash",
211
+ toolArgs: { command: "npm run test:unit" },
212
+ agentContext: "coordinator",
213
+ });
214
+
215
+ expect(result.isViolation).toBe(true);
216
+ expect(result.violationType).toBe("coordinator_ran_tests");
217
+ });
218
+
219
+ it("detects jest from coordinator", () => {
220
+ const result = detectCoordinatorViolation({
221
+ sessionId,
222
+ epicId,
223
+ toolName: "bash",
224
+ toolArgs: { command: "jest --coverage" },
225
+ agentContext: "coordinator",
226
+ });
227
+
228
+ expect(result.isViolation).toBe(true);
229
+ expect(result.violationType).toBe("coordinator_ran_tests");
230
+ });
231
+
232
+ it("does not detect non-test bash commands", () => {
233
+ const result = detectCoordinatorViolation({
234
+ sessionId,
235
+ epicId,
236
+ toolName: "bash",
237
+ toolArgs: { command: "git status" },
238
+ agentContext: "coordinator",
239
+ });
240
+
241
+ expect(result.isViolation).toBe(false);
242
+ });
243
+
244
+ it("does not detect test execution from worker", () => {
245
+ const result = detectCoordinatorViolation({
246
+ sessionId,
247
+ epicId,
248
+ toolName: "bash",
249
+ toolArgs: { command: "bun test" },
250
+ agentContext: "worker",
251
+ });
252
+
253
+ expect(result.isViolation).toBe(false);
254
+ });
255
+ });
256
+
257
+ describe("coordinator_reserved_files violation", () => {
258
+ it("detects swarmmail_reserve from coordinator", () => {
259
+ const result = detectCoordinatorViolation({
260
+ sessionId,
261
+ epicId,
262
+ toolName: "swarmmail_reserve",
263
+ toolArgs: { paths: ["src/auth/**"], reason: "Working on auth" },
264
+ agentContext: "coordinator",
265
+ });
266
+
267
+ expect(result.isViolation).toBe(true);
268
+ expect(result.violationType).toBe("coordinator_reserved_files");
269
+ expect(result.message).toContain("Workers reserve files");
270
+ expect(result.payload.paths).toEqual(["src/auth/**"]);
271
+ });
272
+
273
+ it("detects agentmail_reserve from coordinator", () => {
274
+ const result = detectCoordinatorViolation({
275
+ sessionId,
276
+ epicId,
277
+ toolName: "agentmail_reserve",
278
+ toolArgs: { paths: ["src/lib/**"], reason: "Refactoring" },
279
+ agentContext: "coordinator",
280
+ });
281
+
282
+ expect(result.isViolation).toBe(true);
283
+ expect(result.violationType).toBe("coordinator_reserved_files");
284
+ });
285
+
286
+ it("does not detect reserve from worker", () => {
287
+ const result = detectCoordinatorViolation({
288
+ sessionId,
289
+ epicId,
290
+ toolName: "swarmmail_reserve",
291
+ toolArgs: { paths: ["src/auth/**"], reason: "Working on auth" },
292
+ agentContext: "worker",
293
+ });
294
+
295
+ expect(result.isViolation).toBe(false);
296
+ });
297
+ });
298
+
299
+ describe("no_worker_spawned violation", () => {
300
+ it("detects no spawn after decomposition", () => {
301
+ const result = detectCoordinatorViolation({
302
+ sessionId,
303
+ epicId,
304
+ toolName: "hive_create_epic",
305
+ toolArgs: {
306
+ epic_title: "Add feature",
307
+ subtasks: [
308
+ { title: "Task 1", files: ["a.ts"] },
309
+ { title: "Task 2", files: ["b.ts"] },
310
+ ],
311
+ },
312
+ agentContext: "coordinator",
313
+ checkNoSpawn: true,
314
+ });
315
+
316
+ expect(result.isViolation).toBe(true);
317
+ expect(result.violationType).toBe("no_worker_spawned");
318
+ expect(result.message).toContain("decomposition without spawning");
319
+ expect(result.payload.epic_title).toBe("Add feature");
320
+ expect(result.payload.subtask_count).toBe(2);
321
+ });
322
+
323
+ it("does not flag if workers were spawned", () => {
324
+ const result = detectCoordinatorViolation({
325
+ sessionId,
326
+ epicId,
327
+ toolName: "hive_create_epic",
328
+ toolArgs: {
329
+ epic_title: "Add feature",
330
+ subtasks: [
331
+ { title: "Task 1", files: ["a.ts"] },
332
+ { title: "Task 2", files: ["b.ts"] },
333
+ ],
334
+ },
335
+ agentContext: "coordinator",
336
+ checkNoSpawn: false, // Workers were spawned
337
+ });
338
+
339
+ expect(result.isViolation).toBe(false);
340
+ });
341
+
342
+ it("does not flag from worker agent", () => {
343
+ const result = detectCoordinatorViolation({
344
+ sessionId,
345
+ epicId,
346
+ toolName: "hive_create_epic",
347
+ toolArgs: {
348
+ epic_title: "Add feature",
349
+ subtasks: [{ title: "Task 1", files: ["a.ts"] }],
350
+ },
351
+ agentContext: "worker",
352
+ checkNoSpawn: true,
353
+ });
354
+
355
+ expect(result.isViolation).toBe(false);
356
+ });
357
+ });
358
+
359
+ describe("event capture integration", () => {
360
+ it("captures violation event to session file when violation detected", () => {
361
+ const result = detectCoordinatorViolation({
362
+ sessionId,
363
+ epicId,
364
+ toolName: "edit",
365
+ toolArgs: { filePath: "/test.ts", oldString: "a", newString: "b" },
366
+ agentContext: "coordinator",
367
+ });
368
+
369
+ expect(result.isViolation).toBe(true);
370
+
371
+ // Verify event was written to session file
372
+ const sessionDir = path.join(os.homedir(), ".config", "swarm-tools", "sessions");
373
+ const sessionPath = path.join(sessionDir, `${sessionId}.jsonl`);
374
+ expect(fs.existsSync(sessionPath)).toBe(true);
375
+
376
+ const content = fs.readFileSync(sessionPath, "utf-8");
377
+ const lines = content.trim().split("\n");
378
+ expect(lines.length).toBe(1);
379
+
380
+ const event = JSON.parse(lines[0]);
381
+ expect(event.event_type).toBe("VIOLATION");
382
+ expect(event.violation_type).toBe("coordinator_edited_file");
383
+ expect(event.session_id).toBe(sessionId);
384
+ expect(event.epic_id).toBe(epicId);
385
+ expect(event.payload.tool).toBe("edit");
386
+ });
387
+
388
+ it("does not capture event when no violation", () => {
389
+ const result = detectCoordinatorViolation({
390
+ sessionId,
391
+ epicId,
392
+ toolName: "read",
393
+ toolArgs: { filePath: "/test.ts" },
394
+ agentContext: "coordinator",
395
+ });
396
+
397
+ expect(result.isViolation).toBe(false);
398
+
399
+ // Verify no session file created
400
+ const sessionDir = path.join(os.homedir(), ".config", "swarm-tools", "sessions");
401
+ const sessionPath = path.join(sessionDir, `${sessionId}.jsonl`);
402
+ expect(fs.existsSync(sessionPath)).toBe(false);
403
+ });
404
+ });
405
+ });
406
+
407
+ describe("coordinator context", () => {
408
+ beforeEach(() => {
409
+ clearCoordinatorContext();
410
+ });
411
+
412
+ afterEach(() => {
413
+ clearCoordinatorContext();
414
+ });
415
+
416
+ describe("setCoordinatorContext", () => {
417
+ it("sets coordinator context", () => {
418
+ setCoordinatorContext({
419
+ isCoordinator: true,
420
+ epicId: "test-epic-123",
421
+ sessionId: "test-session-456",
422
+ });
423
+
424
+ const ctx = getCoordinatorContext();
425
+ expect(ctx.isCoordinator).toBe(true);
426
+ expect(ctx.epicId).toBe("test-epic-123");
427
+ expect(ctx.sessionId).toBe("test-session-456");
428
+ expect(ctx.activatedAt).toBeDefined();
429
+ });
430
+
431
+ it("merges with existing context", () => {
432
+ setCoordinatorContext({
433
+ isCoordinator: true,
434
+ sessionId: "session-1",
435
+ });
436
+
437
+ setCoordinatorContext({
438
+ epicId: "epic-1",
439
+ });
440
+
441
+ const ctx = getCoordinatorContext();
442
+ expect(ctx.isCoordinator).toBe(true);
443
+ expect(ctx.sessionId).toBe("session-1");
444
+ expect(ctx.epicId).toBe("epic-1");
445
+ });
446
+ });
447
+
448
+ describe("isInCoordinatorContext", () => {
449
+ it("returns false when not in coordinator context", () => {
450
+ expect(isInCoordinatorContext()).toBe(false);
451
+ });
452
+
453
+ it("returns true when in coordinator context", () => {
454
+ setCoordinatorContext({
455
+ isCoordinator: true,
456
+ epicId: "test-epic",
457
+ });
458
+
459
+ expect(isInCoordinatorContext()).toBe(true);
460
+ });
461
+
462
+ it("returns false after context is cleared", () => {
463
+ setCoordinatorContext({
464
+ isCoordinator: true,
465
+ epicId: "test-epic",
466
+ });
467
+
468
+ clearCoordinatorContext();
469
+
470
+ expect(isInCoordinatorContext()).toBe(false);
471
+ });
472
+ });
473
+
474
+ describe("clearCoordinatorContext", () => {
475
+ it("clears all context", () => {
476
+ setCoordinatorContext({
477
+ isCoordinator: true,
478
+ epicId: "test-epic",
479
+ sessionId: "test-session",
480
+ });
481
+
482
+ clearCoordinatorContext();
483
+
484
+ const ctx = getCoordinatorContext();
485
+ expect(ctx.isCoordinator).toBe(false);
486
+ expect(ctx.epicId).toBeUndefined();
487
+ expect(ctx.sessionId).toBeUndefined();
488
+ });
489
+ });
490
+ });
106
491
  });