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
@@ -68,11 +68,31 @@ function getLog() {
68
68
  * This is NOT about preserving state for a human - it's about the swarm continuing
69
69
  * autonomously after context compression.
70
70
  */
71
- export const SWARM_COMPACTION_CONTEXT = `## 🐝 SWARM ACTIVE - Keep Cooking
71
+ export const SWARM_COMPACTION_CONTEXT = `## 🐝 SWARM ACTIVE - You Are The COORDINATOR
72
72
 
73
- You are the **COORDINATOR** of an active swarm. Context was compacted but the swarm is still running.
73
+ Context was compacted but the swarm is still running. You are the **COORDINATOR**.
74
74
 
75
- **YOUR JOB:** Keep orchestrating. Spawn agents. Monitor progress. Unblock work. Ship it.
75
+ ### NEVER DO THESE (Coordinator Anti-Patterns)
76
+
77
+ **CRITICAL: Coordinators NEVER do implementation work. ALWAYS spawn workers.**
78
+
79
+ - ❌ **NEVER** use \`edit\` or \`write\` tools - SPAWN A WORKER
80
+ - ❌ **NEVER** run tests with \`bash\` - SPAWN A WORKER
81
+ - ❌ **NEVER** implement features yourself - SPAWN A WORKER
82
+ - ❌ **NEVER** "just do it myself to save time" - NO. SPAWN A WORKER.
83
+ - ❌ **NEVER** reserve files with \`swarmmail_reserve\` - Workers reserve files
84
+
85
+ **If you catch yourself about to edit a file, STOP. Use \`swarm_spawn_subtask\` instead.**
86
+
87
+ ### ✅ ALWAYS DO THESE (Coordinator Checklist)
88
+
89
+ On resume, execute this checklist IN ORDER:
90
+
91
+ 1. \`swarm_status(epic_id="<epic>", project_key="<path>")\` - Get current state
92
+ 2. \`swarmmail_inbox(limit=5)\` - Check for agent messages
93
+ 3. For completed work: \`swarm_review\` → \`swarm_review_feedback\`
94
+ 4. For open subtasks: \`swarm_spawn_subtask\` (NOT "do it yourself")
95
+ 5. For blocked work: Investigate, unblock, reassign
76
96
 
77
97
  ### Preserve in Summary
78
98
 
@@ -89,41 +109,31 @@ Extract from session context:
89
109
  \`\`\`
90
110
  ## 🐝 Swarm State
91
111
 
92
- **Epic:** <bd-xxx> - <title>
112
+ **Epic:** <cell-xxx> - <title>
93
113
  **Project:** <path>
94
114
  **Progress:** X/Y subtasks complete
95
115
 
96
116
  **Active:**
97
- - <bd-xxx>: <title> [in_progress] → <agent> working on <files>
117
+ - <cell-xxx>: <title> [in_progress] → <agent> working on <files>
98
118
 
99
119
  **Blocked:**
100
- - <bd-xxx>: <title> - BLOCKED: <reason>
120
+ - <cell-xxx>: <title> - BLOCKED: <reason>
101
121
 
102
122
  **Completed:**
103
- - <bd-xxx>: <title> ✓
123
+ - <cell-xxx>: <title> ✓
104
124
 
105
125
  **Ready to Spawn:**
106
- - <bd-xxx>: <title> (files: <...>)
126
+ - <cell-xxx>: <title> (files: <...>)
107
127
  \`\`\`
108
128
 
109
- ### On Resume - IMMEDIATELY
110
-
111
- 1. \`swarm_status(epic_id="<epic>", project_key="<path>")\` - Get current state
112
- 2. \`swarmmail_inbox(limit=5)\` - Check for agent messages
113
- 3. \`swarm_review(project_key, epic_id, task_id, files_touched)\` - Review any completed work
114
- 4. \`swarm_review_feedback(project_key, task_id, worker_id, status, issues)\` - Approve or request changes
115
- 5. **Spawn ready subtasks** - Don't wait, fire them off
116
- 6. **Unblock blocked work** - Resolve dependencies, reassign if needed
117
- 7. **Collect completed work** - Close done subtasks, verify quality
118
-
119
- ### Keep the Swarm Cooking
129
+ ### Your Role
120
130
 
121
131
  - **Spawn aggressively** - If a subtask is ready and unblocked, spawn an agent
122
132
  - **Monitor actively** - Check status, read messages, respond to blockers
133
+ - **Review work** - Use \`swarm_review\` and \`swarm_review_feedback\` for completed work
123
134
  - **Close the loop** - When all subtasks done, verify and close the epic
124
- - **Don't stop** - The swarm runs until the epic is closed
125
135
 
126
- **You are not waiting for instructions. You are the coordinator. Coordinate.**
136
+ **You are the COORDINATOR. You orchestrate. You do NOT implement. Spawn workers.**
127
137
  `;
128
138
 
129
139
  /**
@@ -236,29 +246,30 @@ interface ToolPart {
236
246
  /**
237
247
  * Tool state (completed tools have input/output we need)
238
248
  */
239
- type ToolState = {
240
- status: "completed";
241
- input: { [key: string]: unknown };
242
- output: string;
243
- title: string;
244
- metadata: { [key: string]: unknown };
245
- time: { start: number; end: number };
246
- } | {
247
- status: string;
248
- [key: string]: unknown;
249
- };
249
+ type ToolState =
250
+ | {
251
+ status: "completed";
252
+ input: { [key: string]: unknown };
253
+ output: string;
254
+ title: string;
255
+ metadata: { [key: string]: unknown };
256
+ time: { start: number; end: number };
257
+ }
258
+ | {
259
+ status: string;
260
+ [key: string]: unknown;
261
+ };
250
262
 
251
263
  /**
252
264
  * SDK Client type (minimal interface for scanSessionMessages)
265
+ *
266
+ * The actual SDK client uses a more complex Options-based API:
267
+ * client.session.messages({ path: { id: sessionID }, query: { limit } })
268
+ *
269
+ * We accept `unknown` and handle the type internally to avoid
270
+ * tight coupling to SDK internals.
253
271
  */
254
- interface OpencodeClient {
255
- session: {
256
- messages: (opts: { sessionID: string; limit?: number }) => Promise<{
257
- info: { id: string; sessionID: string };
258
- parts: ToolPart[];
259
- }[]>;
260
- };
261
- }
272
+ export type OpencodeClient = unknown;
262
273
 
263
274
  /**
264
275
  * Scanned swarm state extracted from session messages
@@ -268,29 +279,32 @@ export interface ScannedSwarmState {
268
279
  epicTitle?: string;
269
280
  projectPath?: string;
270
281
  agentName?: string;
271
- subtasks: Map<string, { title: string; status: string; worker?: string; files?: string[] }>;
282
+ subtasks: Map<
283
+ string,
284
+ { title: string; status: string; worker?: string; files?: string[] }
285
+ >;
272
286
  lastAction?: { tool: string; args: unknown; timestamp: number };
273
287
  }
274
288
 
275
289
  /**
276
290
  * Scan session messages for swarm state using SDK client
277
- *
291
+ *
278
292
  * Extracts swarm coordination state from actual tool calls:
279
293
  * - swarm_spawn_subtask → subtask tracking
280
294
  * - swarmmail_init → agent name, project path
281
295
  * - hive_create_epic → epic ID and title
282
296
  * - swarm_status → epic reference
283
297
  * - swarm_complete → subtask completion
284
- *
298
+ *
285
299
  * @param client - OpenCode SDK client (undefined if not available)
286
300
  * @param sessionID - Session to scan
287
301
  * @param limit - Max messages to fetch (default 100)
288
302
  * @returns Extracted swarm state
289
303
  */
290
304
  export async function scanSessionMessages(
291
- client: OpencodeClient | undefined,
305
+ client: OpencodeClient,
292
306
  sessionID: string,
293
- limit: number = 100
307
+ limit: number = 100,
294
308
  ): Promise<ScannedSwarmState> {
295
309
  const state: ScannedSwarmState = {
296
310
  subtasks: new Map(),
@@ -301,7 +315,22 @@ export async function scanSessionMessages(
301
315
  }
302
316
 
303
317
  try {
304
- const messages = await client.session.messages({ sessionID, limit });
318
+ // SDK client uses Options-based API: { path: { id }, query: { limit } }
319
+ const sdkClient = client as {
320
+ session: {
321
+ messages: (opts: {
322
+ path: { id: string };
323
+ query?: { limit?: number };
324
+ }) => Promise<{ data?: Array<{ info: unknown; parts: ToolPart[] }> }>;
325
+ };
326
+ };
327
+
328
+ const response = await sdkClient.session.messages({
329
+ path: { id: sessionID },
330
+ query: { limit },
331
+ });
332
+
333
+ const messages = response.data || [];
305
334
 
306
335
  for (const message of messages) {
307
336
  for (const part of message.parts) {
@@ -310,7 +339,10 @@ export async function scanSessionMessages(
310
339
  }
311
340
 
312
341
  const { tool, state: toolState } = part;
313
- const { input, output, time } = toolState as Extract<ToolState, { status: "completed" }>;
342
+ const { input, output, time } = toolState as Extract<
343
+ ToolState,
344
+ { status: "completed" }
345
+ >;
314
346
 
315
347
  // Track last action
316
348
  state.lastAction = {
@@ -407,12 +439,102 @@ export async function scanSessionMessages(
407
439
  }
408
440
  }
409
441
  } catch (error) {
442
+ getLog().debug(
443
+ {
444
+ error: error instanceof Error ? error.message : String(error),
445
+ },
446
+ "SDK message scanning failed",
447
+ );
410
448
  // SDK not available or error fetching messages - return what we have
411
449
  }
412
450
 
413
451
  return state;
414
452
  }
415
453
 
454
+ /**
455
+ * Build dynamic swarm state from scanned messages (more precise than hive detection)
456
+ */
457
+ function buildDynamicSwarmStateFromScanned(
458
+ scanned: ScannedSwarmState,
459
+ detected: SwarmState,
460
+ ): string {
461
+ const parts: string[] = [];
462
+
463
+ parts.push("## 🐝 Current Swarm State\n");
464
+
465
+ // Prefer scanned data over detected
466
+ const epicId = scanned.epicId || detected.epicId;
467
+ const epicTitle = scanned.epicTitle || detected.epicTitle;
468
+ const projectPath = scanned.projectPath || detected.projectPath;
469
+
470
+ if (epicId) {
471
+ parts.push(`**Epic:** ${epicId}${epicTitle ? ` - ${epicTitle}` : ""}`);
472
+ }
473
+
474
+ if (scanned.agentName) {
475
+ parts.push(`**Coordinator:** ${scanned.agentName}`);
476
+ }
477
+
478
+ parts.push(`**Project:** ${projectPath}`);
479
+
480
+ // Show detailed subtask info from scanned state
481
+ if (scanned.subtasks.size > 0) {
482
+ parts.push(`\n**Subtasks:**`);
483
+ for (const [id, subtask] of scanned.subtasks) {
484
+ const status = subtask.status === "completed" ? "✓" : `[${subtask.status}]`;
485
+ const worker = subtask.worker ? ` → ${subtask.worker}` : "";
486
+ const files = subtask.files?.length ? ` (${subtask.files.join(", ")})` : "";
487
+ parts.push(` - ${id}: ${subtask.title} ${status}${worker}${files}`);
488
+ }
489
+ } else if (detected.subtasks) {
490
+ // Fall back to counts from hive detection
491
+ const total =
492
+ detected.subtasks.closed +
493
+ detected.subtasks.in_progress +
494
+ detected.subtasks.open +
495
+ detected.subtasks.blocked;
496
+
497
+ if (total > 0) {
498
+ parts.push(`**Subtasks:**`);
499
+ if (detected.subtasks.closed > 0)
500
+ parts.push(` - ${detected.subtasks.closed} closed`);
501
+ if (detected.subtasks.in_progress > 0)
502
+ parts.push(` - ${detected.subtasks.in_progress} in_progress`);
503
+ if (detected.subtasks.open > 0)
504
+ parts.push(` - ${detected.subtasks.open} open`);
505
+ if (detected.subtasks.blocked > 0)
506
+ parts.push(` - ${detected.subtasks.blocked} blocked`);
507
+ }
508
+ }
509
+
510
+ // Show last action if available
511
+ if (scanned.lastAction) {
512
+ parts.push(`\n**Last Action:** \`${scanned.lastAction.tool}\``);
513
+ }
514
+
515
+ if (epicId) {
516
+ parts.push(`\n## 🎯 YOU ARE THE COORDINATOR`);
517
+ parts.push(``);
518
+ parts.push(
519
+ `**Primary role:** Orchestrate workers, review their output, unblock dependencies.`,
520
+ );
521
+ parts.push(`**Spawn workers** for implementation tasks - don't do them yourself.`);
522
+ parts.push(``);
523
+ parts.push(`**RESUME STEPS:**`);
524
+ parts.push(
525
+ `1. Check swarm status: \`swarm_status(epic_id="${epicId}", project_key="${projectPath}")\``,
526
+ );
527
+ parts.push(`2. Check inbox for worker messages: \`swarmmail_inbox(limit=5)\``);
528
+ parts.push(
529
+ `3. For in_progress subtasks: Review worker results with \`swarm_review\``,
530
+ );
531
+ parts.push(`4. For open subtasks: Spawn workers with \`swarm_spawn_subtask\``);
532
+ parts.push(`5. For blocked subtasks: Investigate and unblock`);
533
+ }
534
+
535
+ return parts.join("\n");
536
+ }
537
+
416
538
  // ============================================================================
417
539
  // Swarm Detection
418
540
  // ============================================================================
@@ -678,17 +800,21 @@ async function detectSwarm(): Promise<SwarmDetection> {
678
800
  * Philosophy: Err on the side of continuation. A false positive costs
679
801
  * a bit of context space. A false negative loses the swarm.
680
802
  *
803
+ * @param client - Optional OpenCode SDK client for scanning session messages.
804
+ * When provided, extracts PRECISE swarm state from actual tool calls.
805
+ * When undefined, falls back to hive/swarm-mail heuristic detection.
806
+ *
681
807
  * @example
682
808
  * ```typescript
683
809
  * import { createCompactionHook } from "opencode-swarm-plugin";
684
810
  *
685
- * export const SwarmPlugin: Plugin = async () => ({
811
+ * export const SwarmPlugin: Plugin = async (input) => ({
686
812
  * tool: { ... },
687
- * "experimental.session.compacting": createCompactionHook(),
813
+ * "experimental.session.compacting": createCompactionHook(input.client),
688
814
  * });
689
815
  * ```
690
816
  */
691
- export function createCompactionHook() {
817
+ export function createCompactionHook(client?: OpencodeClient) {
692
818
  return async (
693
819
  input: { sessionID: string },
694
820
  output: { context: string[] },
@@ -699,41 +825,73 @@ export function createCompactionHook() {
699
825
  {
700
826
  session_id: input.sessionID,
701
827
  trigger: "session_compaction",
828
+ has_sdk_client: !!client,
702
829
  },
703
830
  "compaction started",
704
831
  );
705
832
 
706
833
  try {
834
+ // Scan session messages for precise swarm state (if client available)
835
+ const scannedState = await scanSessionMessages(client, input.sessionID);
836
+
837
+ // Also run heuristic detection from hive/swarm-mail
707
838
  const detection = await detectSwarm();
708
839
 
840
+ // Boost confidence if we found swarm evidence in session messages
841
+ let effectiveConfidence = detection.confidence;
842
+ if (scannedState.epicId || scannedState.subtasks.size > 0) {
843
+ // Session messages show swarm activity - this is HIGH confidence
844
+ if (effectiveConfidence === "none" || effectiveConfidence === "low") {
845
+ effectiveConfidence = "medium";
846
+ detection.reasons.push("swarm tool calls found in session");
847
+ }
848
+ if (scannedState.subtasks.size > 0) {
849
+ effectiveConfidence = "high";
850
+ detection.reasons.push(`${scannedState.subtasks.size} subtasks spawned`);
851
+ }
852
+ }
853
+
709
854
  if (
710
- detection.confidence === "high" ||
711
- detection.confidence === "medium"
855
+ effectiveConfidence === "high" ||
856
+ effectiveConfidence === "medium"
712
857
  ) {
713
858
  // Definite or probable swarm - inject full context
714
859
  const header = `[Swarm detected: ${detection.reasons.join(", ")}]\n\n`;
715
-
716
- // Build dynamic state section if we have specific data
860
+
861
+ // Build dynamic state section - prefer scanned state (ground truth) over detected
717
862
  let dynamicState = "";
718
- if (detection.state && detection.state.epicId) {
863
+ if (scannedState.epicId || scannedState.subtasks.size > 0) {
864
+ // Use scanned state (more precise)
865
+ dynamicState =
866
+ buildDynamicSwarmStateFromScanned(
867
+ scannedState,
868
+ detection.state || {
869
+ projectPath: scannedState.projectPath || process.cwd(),
870
+ subtasks: { closed: 0, in_progress: 0, open: 0, blocked: 0 },
871
+ },
872
+ ) + "\n\n";
873
+ } else if (detection.state && detection.state.epicId) {
874
+ // Fall back to hive-detected state
719
875
  dynamicState = buildDynamicSwarmState(detection.state) + "\n\n";
720
876
  }
721
-
877
+
722
878
  const contextContent = header + dynamicState + SWARM_COMPACTION_CONTEXT;
723
879
  output.context.push(contextContent);
724
880
 
725
881
  getLog().info(
726
882
  {
727
- confidence: detection.confidence,
883
+ confidence: effectiveConfidence,
728
884
  context_length: contextContent.length,
729
885
  context_type: "full",
730
886
  reasons: detection.reasons,
731
887
  has_dynamic_state: !!dynamicState,
732
- epic_id: detection.state?.epicId,
888
+ epic_id: scannedState.epicId || detection.state?.epicId,
889
+ scanned_subtasks: scannedState.subtasks.size,
890
+ scanned_agent: scannedState.agentName,
733
891
  },
734
892
  "injected swarm context",
735
893
  );
736
- } else if (detection.confidence === "low") {
894
+ } else if (effectiveConfidence === "low") {
737
895
  // Possible swarm - inject fallback detection prompt
738
896
  const header = `[Possible swarm: ${detection.reasons.join(", ")}]\n\n`;
739
897
  const contextContent = header + SWARM_DETECTION_FALLBACK;
@@ -741,7 +899,7 @@ export function createCompactionHook() {
741
899
 
742
900
  getLog().info(
743
901
  {
744
- confidence: detection.confidence,
902
+ confidence: effectiveConfidence,
745
903
  context_length: contextContent.length,
746
904
  context_type: "fallback",
747
905
  reasons: detection.reasons,
@@ -751,7 +909,7 @@ export function createCompactionHook() {
751
909
  } else {
752
910
  getLog().debug(
753
911
  {
754
- confidence: detection.confidence,
912
+ confidence: effectiveConfidence,
755
913
  context_type: "none",
756
914
  },
757
915
  "no swarm detected, skipping injection",
@@ -764,8 +922,8 @@ export function createCompactionHook() {
764
922
  {
765
923
  duration_ms: duration,
766
924
  success: true,
767
- detected: detection.detected,
768
- confidence: detection.confidence,
925
+ detected: detection.detected || scannedState.epicId !== undefined,
926
+ confidence: effectiveConfidence,
769
927
  context_injected: output.context.length > 0,
770
928
  },
771
929
  "compaction complete",