opencode-swarm-plugin 0.36.0 → 0.37.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.
Files changed (54) hide show
  1. package/.hive/issues.jsonl +16 -4
  2. package/.hive/memories.jsonl +274 -1
  3. package/.turbo/turbo-build.log +4 -4
  4. package/.turbo/turbo-test.log +318 -318
  5. package/CHANGELOG.md +113 -0
  6. package/bin/swarm.test.ts +106 -0
  7. package/bin/swarm.ts +413 -179
  8. package/dist/compaction-hook.d.ts +54 -4
  9. package/dist/compaction-hook.d.ts.map +1 -1
  10. package/dist/eval-capture.d.ts +122 -17
  11. package/dist/eval-capture.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -7
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1278 -619
  15. package/dist/planning-guardrails.d.ts +121 -0
  16. package/dist/planning-guardrails.d.ts.map +1 -1
  17. package/dist/plugin.d.ts +9 -9
  18. package/dist/plugin.d.ts.map +1 -1
  19. package/dist/plugin.js +1283 -329
  20. package/dist/schemas/task.d.ts +0 -1
  21. package/dist/schemas/task.d.ts.map +1 -1
  22. package/dist/swarm-decompose.d.ts +0 -8
  23. package/dist/swarm-decompose.d.ts.map +1 -1
  24. package/dist/swarm-orchestrate.d.ts.map +1 -1
  25. package/dist/swarm-prompts.d.ts +0 -4
  26. package/dist/swarm-prompts.d.ts.map +1 -1
  27. package/dist/swarm-review.d.ts.map +1 -1
  28. package/dist/swarm.d.ts +0 -6
  29. package/dist/swarm.d.ts.map +1 -1
  30. package/evals/README.md +38 -0
  31. package/evals/coordinator-session.eval.ts +154 -0
  32. package/evals/fixtures/coordinator-sessions.ts +328 -0
  33. package/evals/lib/data-loader.ts +69 -0
  34. package/evals/scorers/coordinator-discipline.evalite-test.ts +536 -0
  35. package/evals/scorers/coordinator-discipline.ts +315 -0
  36. package/evals/scorers/index.ts +12 -0
  37. package/examples/plugin-wrapper-template.ts +303 -4
  38. package/package.json +2 -2
  39. package/src/compaction-hook.test.ts +8 -1
  40. package/src/compaction-hook.ts +31 -21
  41. package/src/eval-capture.test.ts +390 -0
  42. package/src/eval-capture.ts +163 -4
  43. package/src/hive.integration.test.ts +148 -0
  44. package/src/hive.ts +89 -0
  45. package/src/index.ts +68 -1
  46. package/src/planning-guardrails.test.ts +387 -2
  47. package/src/planning-guardrails.ts +289 -0
  48. package/src/plugin.ts +10 -10
  49. package/src/swarm-decompose.test.ts +195 -0
  50. package/src/swarm-decompose.ts +72 -1
  51. package/src/swarm-orchestrate.ts +44 -0
  52. package/src/swarm-prompts.ts +20 -0
  53. package/src/swarm-review.integration.test.ts +24 -29
  54. package/src/swarm-review.ts +41 -0
package/src/plugin.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  /**
2
2
  * OpenCode Plugin Entry Point
3
3
  *
4
- * CRITICAL: Only export the plugin function from this file.
4
+ * CRITICAL: Only export the plugin function as DEFAULT from this file.
5
5
  *
6
6
  * OpenCode's plugin loader calls ALL exports as functions during initialization.
7
- * Exporting classes, constants, or non-function values will cause the plugin
8
- * to fail to load with cryptic errors.
7
+ * If you export both named AND default pointing to the same function, the plugin
8
+ * gets registered TWICE, causing hooks to fire multiple times.
9
9
  *
10
10
  * If you need to export utilities for external use, add them to src/index.ts instead.
11
11
  *
12
12
  * @example
13
- * // ✅ CORRECT - only export the plugin function
13
+ * // ✅ CORRECT - only default export
14
14
  * export default SwarmPlugin;
15
15
  *
16
- * // ❌ WRONG - will break plugin loading
17
- * export const VERSION = "1.0.0";
18
- * export class Helper {}
16
+ * // ❌ WRONG - causes double registration
17
+ * export { SwarmPlugin };
18
+ * export default SwarmPlugin;
19
19
  */
20
- import { SwarmPlugin } from "./index";
20
+ import SwarmPlugin from "./index";
21
21
 
22
- // Only export the plugin function - nothing else!
23
- export { SwarmPlugin };
22
+ // Only default export - no named exports!
23
+ export default SwarmPlugin;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Swarm Decompose Unit Tests
3
+ *
4
+ * Tests for task decomposition, validation, and eval capture integration.
5
+ *
6
+ * TDD: Testing eval capture integration - verifies captureDecomposition() is called
7
+ * after successful validation with correct parameters.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test, mock } from "bun:test";
10
+ import * as fs from "node:fs";
11
+ import { swarm_validate_decomposition } from "./swarm-decompose";
12
+ import * as evalCapture from "./eval-capture.js";
13
+
14
+ // ============================================================================
15
+ // Test Setup
16
+ // ============================================================================
17
+
18
+ const mockContext = {
19
+ sessionID: `test-decompose-${Date.now()}`,
20
+ messageID: `test-message-${Date.now()}`,
21
+ agent: "test-agent",
22
+ abort: new AbortController().signal,
23
+ };
24
+
25
+ let testProjectPath: string;
26
+
27
+ beforeEach(() => {
28
+ testProjectPath = `/tmp/test-swarm-decompose-${Date.now()}`;
29
+ fs.mkdirSync(testProjectPath, { recursive: true });
30
+ });
31
+
32
+ afterEach(() => {
33
+ if (fs.existsSync(testProjectPath)) {
34
+ fs.rmSync(testProjectPath, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ // ============================================================================
39
+ // Eval Capture Integration Tests
40
+ // ============================================================================
41
+
42
+ describe("captureDecomposition integration", () => {
43
+ test("calls captureDecomposition after successful validation with all params", async () => {
44
+ // Mock captureDecomposition to spy on calls
45
+ const captureDecompositionSpy = mock(() => ({
46
+ id: "test-epic-123",
47
+ timestamp: new Date().toISOString(),
48
+ task: "Add user authentication",
49
+ }));
50
+ const original = evalCapture.captureDecomposition;
51
+ // @ts-expect-error - mocking for test
52
+ evalCapture.captureDecomposition = captureDecompositionSpy;
53
+
54
+ const validCellTree = JSON.stringify({
55
+ epic: {
56
+ title: "Add OAuth",
57
+ description: "Implement OAuth authentication",
58
+ },
59
+ subtasks: [
60
+ {
61
+ title: "Add OAuth provider config",
62
+ description: "Set up Google OAuth",
63
+ files: ["src/auth/google.ts", "src/auth/config.ts"],
64
+ dependencies: [],
65
+ estimated_complexity: 2,
66
+ },
67
+ {
68
+ title: "Add login UI",
69
+ description: "Create login button component",
70
+ files: ["src/components/LoginButton.tsx"],
71
+ dependencies: [0],
72
+ estimated_complexity: 1,
73
+ },
74
+ ],
75
+ });
76
+
77
+ const result = await swarm_validate_decomposition.execute(
78
+ {
79
+ response: validCellTree,
80
+ project_path: testProjectPath,
81
+ task: "Add user authentication",
82
+ context: "Using NextAuth.js",
83
+ strategy: "feature-based" as const,
84
+ epic_id: "test-epic-123",
85
+ },
86
+ mockContext,
87
+ );
88
+
89
+ const parsed = JSON.parse(result);
90
+ expect(parsed.valid).toBe(true);
91
+
92
+ // Verify captureDecomposition was called with correct params
93
+ expect(captureDecompositionSpy).toHaveBeenCalledTimes(1);
94
+ const callArgs = captureDecompositionSpy.mock.calls[0][0];
95
+ expect(callArgs.epicId).toBe("test-epic-123");
96
+ expect(callArgs.projectPath).toBe(testProjectPath);
97
+ expect(callArgs.task).toBe("Add user authentication");
98
+ expect(callArgs.context).toBe("Using NextAuth.js");
99
+ expect(callArgs.strategy).toBe("feature-based");
100
+ expect(callArgs.epicTitle).toBe("Add OAuth");
101
+ expect(callArgs.epicDescription).toBe("Implement OAuth authentication");
102
+ expect(callArgs.subtasks).toHaveLength(2);
103
+ expect(callArgs.subtasks[0].title).toBe("Add OAuth provider config");
104
+
105
+ // Restore
106
+ // @ts-expect-error - restoring mock
107
+ evalCapture.captureDecomposition = original;
108
+ });
109
+
110
+ test("does not call captureDecomposition when validation fails", async () => {
111
+ const captureDecompositionSpy = mock(() => ({}));
112
+ const original = evalCapture.captureDecomposition;
113
+ // @ts-expect-error - mocking for test
114
+ evalCapture.captureDecomposition = captureDecompositionSpy;
115
+
116
+ // Invalid CellTree - missing required fields
117
+ const invalidCellTree = JSON.stringify({
118
+ epic: { title: "Missing subtasks" },
119
+ // No subtasks array
120
+ });
121
+
122
+ const result = await swarm_validate_decomposition.execute(
123
+ {
124
+ response: invalidCellTree,
125
+ project_path: testProjectPath,
126
+ task: "Add auth",
127
+ strategy: "auto" as const,
128
+ epic_id: "test-epic-456",
129
+ },
130
+ mockContext,
131
+ );
132
+
133
+ const parsed = JSON.parse(result);
134
+ expect(parsed.valid).toBe(false);
135
+
136
+ // Verify captureDecomposition was NOT called
137
+ expect(captureDecompositionSpy).not.toHaveBeenCalled();
138
+
139
+ // Restore
140
+ // @ts-expect-error - restoring mock
141
+ evalCapture.captureDecomposition = original;
142
+ });
143
+
144
+ test("handles optional context and description fields", async () => {
145
+ const captureDecompositionSpy = mock(() => ({
146
+ id: "test-epic-789",
147
+ timestamp: new Date().toISOString(),
148
+ task: "Fix the auth bug",
149
+ }));
150
+ const original = evalCapture.captureDecomposition;
151
+ // @ts-expect-error - mocking for test
152
+ evalCapture.captureDecomposition = captureDecompositionSpy;
153
+
154
+ const validCellTree = JSON.stringify({
155
+ epic: {
156
+ title: "Fix bug",
157
+ // No description
158
+ },
159
+ subtasks: [
160
+ {
161
+ title: "Add test",
162
+ files: ["src/test.ts"],
163
+ dependencies: [],
164
+ estimated_complexity: 1,
165
+ },
166
+ ],
167
+ });
168
+
169
+ const result = await swarm_validate_decomposition.execute(
170
+ {
171
+ response: validCellTree,
172
+ project_path: testProjectPath,
173
+ task: "Fix the auth bug",
174
+ // No context
175
+ strategy: "risk-based" as const,
176
+ epic_id: "test-epic-789",
177
+ },
178
+ mockContext,
179
+ );
180
+
181
+ const parsed = JSON.parse(result);
182
+ expect(parsed.valid).toBe(true);
183
+
184
+ // Verify captureDecomposition was called without optional fields
185
+ expect(captureDecompositionSpy).toHaveBeenCalledTimes(1);
186
+ const callArgs = captureDecompositionSpy.mock.calls[0][0];
187
+ expect(callArgs.epicId).toBe("test-epic-789");
188
+ expect(callArgs.context).toBeUndefined();
189
+ expect(callArgs.epicDescription).toBeUndefined();
190
+
191
+ // Restore
192
+ // @ts-expect-error - restoring mock
193
+ evalCapture.captureDecomposition = original;
194
+ });
195
+ });
@@ -20,6 +20,7 @@ import {
20
20
  NEGATIVE_MARKERS,
21
21
  type DecompositionStrategy,
22
22
  } from "./swarm-strategies";
23
+ import { captureCoordinatorEvent } from "./eval-capture.js";
23
24
 
24
25
  // ============================================================================
25
26
  // Decomposition Prompt (temporary - will be moved to swarm-prompts.ts)
@@ -534,11 +535,31 @@ export const swarm_decompose = tool({
534
535
  * Use this after the agent responds to swarm:decompose to validate the structure.
535
536
  */
536
537
  export const swarm_validate_decomposition = tool({
537
- description: "Validate a decomposition response against CellTreeSchema",
538
+ description: "Validate a decomposition response against CellTreeSchema and capture for eval",
538
539
  args: {
539
540
  response: tool.schema
540
541
  .string()
541
542
  .describe("JSON response from agent (CellTree format)"),
543
+ project_path: tool.schema
544
+ .string()
545
+ .optional()
546
+ .describe("Project path for eval capture"),
547
+ task: tool.schema
548
+ .string()
549
+ .optional()
550
+ .describe("Original task description for eval capture"),
551
+ context: tool.schema
552
+ .string()
553
+ .optional()
554
+ .describe("Context provided for decomposition"),
555
+ strategy: tool.schema
556
+ .enum(["file-based", "feature-based", "risk-based", "auto"])
557
+ .optional()
558
+ .describe("Decomposition strategy used"),
559
+ epic_id: tool.schema
560
+ .string()
561
+ .optional()
562
+ .describe("Epic ID for eval capture"),
542
563
  },
543
564
  async execute(args) {
544
565
  try {
@@ -596,6 +617,37 @@ export const swarm_validate_decomposition = tool({
596
617
  validated.subtasks,
597
618
  );
598
619
 
620
+ // Capture decomposition for eval if all required params provided
621
+ if (
622
+ args.project_path &&
623
+ args.task &&
624
+ args.strategy &&
625
+ args.epic_id
626
+ ) {
627
+ try {
628
+ const { captureDecomposition } = await import("./eval-capture.js");
629
+ captureDecomposition({
630
+ epicId: args.epic_id,
631
+ projectPath: args.project_path,
632
+ task: args.task,
633
+ context: args.context,
634
+ strategy: args.strategy,
635
+ epicTitle: validated.epic.title,
636
+ epicDescription: validated.epic.description,
637
+ subtasks: validated.subtasks.map((s) => ({
638
+ title: s.title,
639
+ description: s.description,
640
+ files: s.files,
641
+ dependencies: s.dependencies,
642
+ estimated_complexity: s.estimated_complexity,
643
+ })),
644
+ });
645
+ } catch (error) {
646
+ // Non-fatal - don't block validation if capture fails
647
+ console.warn("[swarm_validate_decomposition] Failed to capture decomposition:", error);
648
+ }
649
+ }
650
+
599
651
  return JSON.stringify(
600
652
  {
601
653
  valid: true,
@@ -722,6 +774,25 @@ export const swarm_delegate_planning = tool({
722
774
  strategyReasoning = selection.reasoning;
723
775
  }
724
776
 
777
+ // Capture strategy selection decision
778
+ try {
779
+ captureCoordinatorEvent({
780
+ session_id: process.env.OPENCODE_SESSION_ID || "unknown",
781
+ epic_id: "planning", // No epic ID yet - this is pre-decomposition
782
+ timestamp: new Date().toISOString(),
783
+ event_type: "DECISION",
784
+ decision_type: "strategy_selected",
785
+ payload: {
786
+ strategy: selectedStrategy,
787
+ reasoning: strategyReasoning,
788
+ task_preview: args.task.slice(0, 100),
789
+ },
790
+ });
791
+ } catch (error) {
792
+ // Non-fatal - don't block planning if capture fails
793
+ console.warn("[swarm_delegate_planning] Failed to capture strategy_selected:", error);
794
+ }
795
+
725
796
  // Query CASS for similar past tasks
726
797
  let cassContext = "";
727
798
  let cassResultInfo: {
@@ -83,6 +83,7 @@ import {
83
83
  isReviewApproved,
84
84
  getReviewStatus,
85
85
  } from "./swarm-review";
86
+ import { captureCoordinatorEvent } from "./eval-capture.js";
86
87
 
87
88
  // ============================================================================
88
89
  // Helper Functions
@@ -1709,6 +1710,28 @@ Files touched: ${args.files_touched?.join(", ") || "none recorded"}`,
1709
1710
  },
1710
1711
  };
1711
1712
 
1713
+ // Capture subtask completion outcome
1714
+ try {
1715
+ const durationMs = args.start_time ? Date.now() - args.start_time : 0;
1716
+ captureCoordinatorEvent({
1717
+ session_id: process.env.OPENCODE_SESSION_ID || "unknown",
1718
+ epic_id: epicId,
1719
+ timestamp: new Date().toISOString(),
1720
+ event_type: "OUTCOME",
1721
+ outcome_type: "subtask_success",
1722
+ payload: {
1723
+ bead_id: args.bead_id,
1724
+ duration_ms: durationMs,
1725
+ files_touched: args.files_touched || [],
1726
+ verification_passed: verificationResult?.passed ?? false,
1727
+ verification_skipped: args.skip_verification ?? false,
1728
+ },
1729
+ });
1730
+ } catch (error) {
1731
+ // Non-fatal - don't block completion if capture fails
1732
+ console.warn("[swarm_complete] Failed to capture subtask_success:", error);
1733
+ }
1734
+
1712
1735
  return JSON.stringify(response, null, 2);
1713
1736
  } catch (error) {
1714
1737
  // CRITICAL: Notify coordinator of failure via swarm mail
@@ -1796,6 +1819,27 @@ Files touched: ${args.files_touched?.join(", ") || "none recorded"}`,
1796
1819
  console.error(`[swarm_complete] Original error:`, error);
1797
1820
  }
1798
1821
 
1822
+ // Capture subtask failure outcome
1823
+ try {
1824
+ const durationMs = args.start_time ? Date.now() - args.start_time : 0;
1825
+ captureCoordinatorEvent({
1826
+ session_id: process.env.OPENCODE_SESSION_ID || "unknown",
1827
+ epic_id: epicId,
1828
+ timestamp: new Date().toISOString(),
1829
+ event_type: "OUTCOME",
1830
+ outcome_type: "subtask_failed",
1831
+ payload: {
1832
+ bead_id: args.bead_id,
1833
+ duration_ms: durationMs,
1834
+ failed_step: failedStep,
1835
+ error_message: errorMessage.slice(0, 500),
1836
+ },
1837
+ });
1838
+ } catch (captureError) {
1839
+ // Non-fatal - don't block error return if capture fails
1840
+ console.warn("[swarm_complete] Failed to capture subtask_failed:", captureError);
1841
+ }
1842
+
1799
1843
  // Return structured error instead of throwing
1800
1844
  // This ensures the agent sees the actual error message
1801
1845
  return JSON.stringify(
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { tool } from "@opencode-ai/plugin";
16
16
  import { generateWorkerHandoff } from "./swarm-orchestrate";
17
+ import { captureCoordinatorEvent } from "./eval-capture.js";
17
18
 
18
19
  // ============================================================================
19
20
  // Prompt Templates
@@ -1107,6 +1108,25 @@ export const swarm_spawn_subtask = tool({
1107
1108
  .replace(/{files_touched}/g, filesJoined)
1108
1109
  .replace(/{worker_id}/g, "worker"); // Will be filled by actual worker name
1109
1110
 
1111
+ // Capture worker spawn decision
1112
+ try {
1113
+ captureCoordinatorEvent({
1114
+ session_id: process.env.OPENCODE_SESSION_ID || "unknown",
1115
+ epic_id: args.epic_id,
1116
+ timestamp: new Date().toISOString(),
1117
+ event_type: "DECISION",
1118
+ decision_type: "worker_spawned",
1119
+ payload: {
1120
+ bead_id: args.bead_id,
1121
+ files: args.files,
1122
+ worker_model: selectedModel,
1123
+ },
1124
+ });
1125
+ } catch (error) {
1126
+ // Non-fatal - don't block spawn if capture fails
1127
+ console.warn("[swarm_spawn_subtask] Failed to capture worker_spawned:", error);
1128
+ }
1129
+
1110
1130
  return JSON.stringify(
1111
1131
  {
1112
1132
  prompt,
@@ -2,18 +2,15 @@
2
2
  * Integration tests for swarm review feedback flow
3
3
  *
4
4
  * Tests the coordinator review feedback workflow with real HiveAdapter and swarm-mail.
5
- * Verifies that review approval/rejection properly updates state and sends messages.
5
+ * Verifies that review approval/rejection properly updates state.
6
6
  *
7
- * **STATUS**: URL_INVALID bug FIXED by commit 7bf9385 (libSQL URL normalization).
8
- * Tests now execute without URL errors. sendSwarmMessage successfully creates adapters.
9
- *
10
- * **REMAINING ISSUE**: Message retrieval not working. getInbox returns empty even though
11
- * sendSwarmMessage succeeds. Possible causes:
12
- * - Database adapter instance mismatch (sendSwarmMessage creates new adapter each call)
13
- * - Message projection not materializing from events
14
- * - Database path resolution issue between send and receive
15
- *
16
- * Tests currently SKIPPED pending message retrieval fix.
7
+ * **ARCHITECTURE**: Coordinator-driven retry pattern (swarm_spawn_retry)
8
+ * - `approved` status: Sends message to worker (worker can complete)
9
+ * - `needs_changes` status: NO message sent (worker is dead, coordinator spawns retry)
10
+ * - After 3 rejections: Task marked blocked, NO message sent
11
+ *
12
+ * This aligns with the "worker is dead" philosophy - failed reviews require coordinator
13
+ * intervention via swarm_spawn_retry, not worker self-retry.
17
14
  */
18
15
 
19
16
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
@@ -195,23 +192,23 @@ describe("swarm_review integration", () => {
195
192
  expect(feedbackParsed.attempt).toBe(1);
196
193
  expect(feedbackParsed.remaining_attempts).toBe(2);
197
194
 
198
- // Verify retry count incremented
199
- expect(feedbackParsed.attempt).toBe(1);
200
-
201
- // Verify message was sent with issues
195
+ // Verify retry_context is provided for coordinator to spawn retry
196
+ expect(feedbackParsed.retry_context).toBeDefined();
197
+ expect(feedbackParsed.retry_context.task_id).toBe(subtask.id);
198
+ expect(feedbackParsed.retry_context.attempt).toBe(1);
199
+ expect(feedbackParsed.retry_context.max_attempts).toBe(3);
200
+ expect(feedbackParsed.retry_context.issues).toEqual(issues);
201
+ expect(feedbackParsed.retry_context.next_action).toContain("swarm_spawn_retry");
202
+
203
+ // ARCHITECTURE CHANGE: No longer sends message to worker
204
+ // Worker is considered "dead" - coordinator must spawn retry
205
+ // Inbox should remain empty
202
206
  const messages = await swarmMail.getInbox(
203
207
  testProjectPath,
204
208
  "TestWorker",
205
209
  { limit: 10 }
206
210
  );
207
- expect(messages.length).toBeGreaterThan(0);
208
-
209
- const needsChangesMessage = messages.find((m) =>
210
- m.subject.includes("NEEDS CHANGES")
211
- );
212
- expect(needsChangesMessage).toBeDefined();
213
- expect(needsChangesMessage?.subject).toContain(subtask.id);
214
- expect(needsChangesMessage?.subject).toContain("attempt 1/3");
211
+ expect(messages.length).toBe(0);
215
212
  });
216
213
 
217
214
  test("3-strike rule: task marked blocked after 3 rejections", async () => {
@@ -275,16 +272,14 @@ describe("swarm_review integration", () => {
275
272
  const updatedCell = await hive.getCell(testProjectPath, subtask.id);
276
273
  expect(updatedCell?.status).toBe("blocked");
277
274
 
278
- // Verify final failure message was sent
275
+ // ARCHITECTURE CHANGE: No longer sends failure message
276
+ // Worker is dead, coordinator handles escalation
277
+ // Inbox should remain empty
279
278
  const messages = await swarmMail.getInbox(
280
279
  testProjectPath,
281
280
  "TestWorker",
282
281
  { limit: 10 }
283
282
  );
284
-
285
- const failedMessage = messages.find((m) => m.subject.includes("FAILED"));
286
- expect(failedMessage).toBeDefined();
287
- expect(failedMessage?.subject).toContain("max review attempts reached");
288
- expect(failedMessage?.importance).toBe("urgent");
283
+ expect(messages.length).toBe(0);
289
284
  });
290
285
  });
@@ -18,6 +18,7 @@ import { tool } from "@opencode-ai/plugin";
18
18
  import { z } from "zod";
19
19
  import { sendSwarmMessage, type HiveAdapter } from "swarm-mail";
20
20
  import { getHiveAdapter } from "./hive";
21
+ import { captureCoordinatorEvent } from "./eval-capture.js";
21
22
 
22
23
  // ============================================================================
23
24
  // Types & Schemas
@@ -508,6 +509,25 @@ export const swarm_review_feedback = tool({
508
509
  // Mark as approved and clear attempts
509
510
  markReviewApproved(args.task_id);
510
511
 
512
+ // Capture review approval decision
513
+ try {
514
+ captureCoordinatorEvent({
515
+ session_id: process.env.OPENCODE_SESSION_ID || "unknown",
516
+ epic_id: epicId,
517
+ timestamp: new Date().toISOString(),
518
+ event_type: "DECISION",
519
+ decision_type: "review_completed",
520
+ payload: {
521
+ task_id: args.task_id,
522
+ status: "approved",
523
+ retry_count: 0,
524
+ },
525
+ });
526
+ } catch (error) {
527
+ // Non-fatal - don't block approval if capture fails
528
+ console.warn("[swarm_review_feedback] Failed to capture review_completed:", error);
529
+ }
530
+
511
531
  // Send approval message
512
532
  await sendSwarmMessage({
513
533
  projectPath: args.project_key,
@@ -539,6 +559,27 @@ You may now complete the task with \`swarm_complete\`.`,
539
559
  const attemptNumber = incrementAttempt(args.task_id);
540
560
  const remaining = MAX_REVIEW_ATTEMPTS - attemptNumber;
541
561
 
562
+ // Capture review rejection decision
563
+ try {
564
+ captureCoordinatorEvent({
565
+ session_id: process.env.OPENCODE_SESSION_ID || "unknown",
566
+ epic_id: epicId,
567
+ timestamp: new Date().toISOString(),
568
+ event_type: "DECISION",
569
+ decision_type: "review_completed",
570
+ payload: {
571
+ task_id: args.task_id,
572
+ status: "needs_changes",
573
+ retry_count: attemptNumber,
574
+ remaining_attempts: remaining,
575
+ issues_count: parsedIssues.length,
576
+ },
577
+ });
578
+ } catch (error) {
579
+ // Non-fatal - don't block feedback if capture fails
580
+ console.warn("[swarm_review_feedback] Failed to capture review_completed:", error);
581
+ }
582
+
542
583
  // Check if task should fail
543
584
  if (remaining <= 0) {
544
585
  // Mark task as blocked using HiveAdapter