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
@@ -14,15 +14,65 @@
14
14
  * - SWARM_PROJECT_DIR: Project directory (critical for database path)
15
15
  */
16
16
  import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin";
17
+ import type { ToolPart } from "@opencode-ai/sdk";
17
18
  import { tool } from "@opencode-ai/plugin";
18
19
  import { spawn } from "child_process";
20
+ import { appendFileSync, mkdirSync, existsSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
19
23
 
20
24
  const SWARM_CLI = "swarm";
21
25
 
26
+ // =============================================================================
27
+ // File-based Logging (writes to ~/.config/swarm-tools/logs/)
28
+ // =============================================================================
29
+
30
+ const LOG_DIR = join(homedir(), ".config", "swarm-tools", "logs");
31
+ const COMPACTION_LOG = join(LOG_DIR, "compaction.log");
32
+
33
+ /**
34
+ * Ensure log directory exists
35
+ */
36
+ function ensureLogDir(): void {
37
+ if (!existsSync(LOG_DIR)) {
38
+ mkdirSync(LOG_DIR, { recursive: true });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Log a compaction event to file (JSON lines format, compatible with `swarm log`)
44
+ *
45
+ * @param level - Log level (info, debug, warn, error)
46
+ * @param msg - Log message
47
+ * @param data - Additional structured data
48
+ */
49
+ function logCompaction(
50
+ level: "info" | "debug" | "warn" | "error",
51
+ msg: string,
52
+ data?: Record<string, unknown>,
53
+ ): void {
54
+ try {
55
+ ensureLogDir();
56
+ const entry = JSON.stringify({
57
+ time: new Date().toISOString(),
58
+ level,
59
+ msg,
60
+ ...data,
61
+ });
62
+ appendFileSync(COMPACTION_LOG, entry + "\n");
63
+ } catch {
64
+ // Silently fail - logging should never break the plugin
65
+ }
66
+ }
67
+
22
68
  // Module-level project directory - set during plugin initialization
23
69
  // This is CRITICAL: without it, the CLI uses process.cwd() which may be wrong
24
70
  let projectDirectory: string = process.cwd();
25
71
 
72
+ // Module-level SDK client - set during plugin initialization
73
+ // Used for scanning session messages during compaction
74
+ let sdkClient: any = null;
75
+
26
76
  // =============================================================================
27
77
  // CLI Execution Helper
28
78
  // =============================================================================
@@ -952,26 +1002,71 @@ interface SwarmStateSnapshot {
952
1002
  * Shells out to swarm CLI to get real data.
953
1003
  */
954
1004
  async function querySwarmState(sessionID: string): Promise<SwarmStateSnapshot> {
1005
+ const startTime = Date.now();
1006
+
1007
+ logCompaction("debug", "query_swarm_state_start", {
1008
+ session_id: sessionID,
1009
+ project_directory: projectDirectory,
1010
+ });
1011
+
955
1012
  try {
956
1013
  // Query cells via swarm CLI
957
- const cellsResult = await new Promise<{ exitCode: number; stdout: string }>(
1014
+ const cliStart = Date.now();
1015
+ const cellsResult = await new Promise<{ exitCode: number; stdout: string; stderr: string }>(
958
1016
  (resolve) => {
959
1017
  const proc = spawn(SWARM_CLI, ["tool", "hive_query"], {
960
1018
  cwd: projectDirectory,
961
1019
  stdio: ["ignore", "pipe", "pipe"],
962
1020
  });
963
1021
  let stdout = "";
1022
+ let stderr = "";
964
1023
  proc.stdout.on("data", (d) => {
965
1024
  stdout += d;
966
1025
  });
1026
+ proc.stderr.on("data", (d) => {
1027
+ stderr += d;
1028
+ });
967
1029
  proc.on("close", (exitCode) =>
968
- resolve({ exitCode: exitCode ?? 1, stdout }),
1030
+ resolve({ exitCode: exitCode ?? 1, stdout, stderr }),
969
1031
  );
970
1032
  },
971
1033
  );
1034
+ const cliDuration = Date.now() - cliStart;
1035
+
1036
+ logCompaction("debug", "query_swarm_state_cli_complete", {
1037
+ session_id: sessionID,
1038
+ duration_ms: cliDuration,
1039
+ exit_code: cellsResult.exitCode,
1040
+ stdout_length: cellsResult.stdout.length,
1041
+ stderr_length: cellsResult.stderr.length,
1042
+ });
972
1043
 
973
- const cells =
974
- cellsResult.exitCode === 0 ? JSON.parse(cellsResult.stdout) : [];
1044
+ let cells: any[] = [];
1045
+ if (cellsResult.exitCode === 0) {
1046
+ try {
1047
+ const parsed = JSON.parse(cellsResult.stdout);
1048
+ // Handle wrapped response: { success: true, data: [...] }
1049
+ cells = Array.isArray(parsed) ? parsed : (parsed?.data ?? []);
1050
+ } catch (parseErr) {
1051
+ logCompaction("error", "query_swarm_state_parse_failed", {
1052
+ session_id: sessionID,
1053
+ error: parseErr instanceof Error ? parseErr.message : String(parseErr),
1054
+ stdout_preview: cellsResult.stdout.substring(0, 500),
1055
+ });
1056
+ }
1057
+ }
1058
+
1059
+ logCompaction("debug", "query_swarm_state_cells_parsed", {
1060
+ session_id: sessionID,
1061
+ cell_count: cells.length,
1062
+ cells: cells.map((c: any) => ({
1063
+ id: c.id,
1064
+ title: c.title,
1065
+ type: c.type,
1066
+ status: c.status,
1067
+ parent_id: c.parent_id,
1068
+ })),
1069
+ });
975
1070
 
976
1071
  // Find active epic (first unclosed epic with subtasks)
977
1072
  const openEpics = cells.filter(
@@ -980,6 +1075,12 @@ async function querySwarmState(sessionID: string): Promise<SwarmStateSnapshot> {
980
1075
  );
981
1076
  const epic = openEpics[0];
982
1077
 
1078
+ logCompaction("debug", "query_swarm_state_epics", {
1079
+ session_id: sessionID,
1080
+ open_epic_count: openEpics.length,
1081
+ selected_epic: epic ? { id: epic.id, title: epic.title, status: epic.status } : null,
1082
+ });
1083
+
983
1084
  // Get subtasks if we have an epic
984
1085
  const subtasks =
985
1086
  epic && epic.id
@@ -988,15 +1089,26 @@ async function querySwarmState(sessionID: string): Promise<SwarmStateSnapshot> {
988
1089
  )
989
1090
  : [];
990
1091
 
1092
+ logCompaction("debug", "query_swarm_state_subtasks", {
1093
+ session_id: sessionID,
1094
+ subtask_count: subtasks.length,
1095
+ subtasks: subtasks.map((s: any) => ({
1096
+ id: s.id,
1097
+ title: s.title,
1098
+ status: s.status,
1099
+ files: s.files,
1100
+ })),
1101
+ });
1102
+
991
1103
  // TODO: Query swarm mail for messages and reservations
992
1104
  // For MVP, use empty arrays - the fallback chain handles this
993
1105
  const messages: SwarmStateSnapshot["messages"] = [];
994
1106
  const reservations: SwarmStateSnapshot["reservations"] = [];
995
1107
 
996
- // Run detection for confidence
1108
+ // Run detection for confidence (already logged internally)
997
1109
  const detection = await detectSwarm();
998
1110
 
999
- return {
1111
+ const snapshot: SwarmStateSnapshot = {
1000
1112
  sessionID,
1001
1113
  detection: {
1002
1114
  confidence: detection.confidence,
@@ -1023,7 +1135,27 @@ async function querySwarmState(sessionID: string): Promise<SwarmStateSnapshot> {
1023
1135
  messages,
1024
1136
  reservations,
1025
1137
  };
1138
+
1139
+ const totalDuration = Date.now() - startTime;
1140
+ logCompaction("debug", "query_swarm_state_complete", {
1141
+ session_id: sessionID,
1142
+ duration_ms: totalDuration,
1143
+ has_epic: !!snapshot.epic,
1144
+ epic_id: snapshot.epic?.id,
1145
+ subtask_count: snapshot.epic?.subtasks?.length ?? 0,
1146
+ message_count: snapshot.messages.length,
1147
+ reservation_count: snapshot.reservations.length,
1148
+ });
1149
+
1150
+ return snapshot;
1026
1151
  } catch (err) {
1152
+ logCompaction("error", "query_swarm_state_exception", {
1153
+ session_id: sessionID,
1154
+ error: err instanceof Error ? err.message : String(err),
1155
+ stack: err instanceof Error ? err.stack : undefined,
1156
+ duration_ms: Date.now() - startTime,
1157
+ });
1158
+
1027
1159
  // If query fails, return minimal snapshot
1028
1160
  const detection = await detectSwarm();
1029
1161
  return {
@@ -1049,10 +1181,19 @@ async function querySwarmState(sessionID: string): Promise<SwarmStateSnapshot> {
1049
1181
  async function generateCompactionPrompt(
1050
1182
  snapshot: SwarmStateSnapshot,
1051
1183
  ): Promise<string | null> {
1052
- try {
1053
- const liteModel =
1054
- process.env.OPENCODE_LITE_MODEL || "claude-3-5-haiku-20241022";
1184
+ const startTime = Date.now();
1185
+ const liteModel = process.env.OPENCODE_LITE_MODEL || "__SWARM_LITE_MODEL__";
1186
+
1187
+ logCompaction("debug", "generate_compaction_prompt_start", {
1188
+ session_id: snapshot.sessionID,
1189
+ lite_model: liteModel,
1190
+ has_epic: !!snapshot.epic,
1191
+ epic_id: snapshot.epic?.id,
1192
+ subtask_count: snapshot.epic?.subtasks?.length ?? 0,
1193
+ snapshot_size: JSON.stringify(snapshot).length,
1194
+ });
1055
1195
 
1196
+ try {
1056
1197
  const promptText = `You are generating a continuation prompt for a compacted swarm coordination session.
1057
1198
 
1058
1199
  Analyze this swarm state and generate a structured markdown prompt that will be given to the resumed session:
@@ -1100,6 +1241,14 @@ You are resuming coordination of an active swarm that was interrupted by context
1100
1241
 
1101
1242
  Keep the prompt concise but actionable. Use actual data from the snapshot, not placeholders.`;
1102
1243
 
1244
+ logCompaction("debug", "generate_compaction_prompt_calling_llm", {
1245
+ session_id: snapshot.sessionID,
1246
+ prompt_length: promptText.length,
1247
+ model: liteModel,
1248
+ command: `opencode run -m ${liteModel} -- <prompt>`,
1249
+ });
1250
+
1251
+ const llmStart = Date.now();
1103
1252
  const result = await new Promise<{ exitCode: number; stdout: string; stderr: string }>(
1104
1253
  (resolve, reject) => {
1105
1254
  const proc = spawn("opencode", ["run", "-m", liteModel, "--", promptText], {
@@ -1133,24 +1282,275 @@ Keep the prompt concise but actionable. Use actual data from the snapshot, not p
1133
1282
  }, 30000);
1134
1283
  },
1135
1284
  );
1285
+ const llmDuration = Date.now() - llmStart;
1286
+
1287
+ logCompaction("debug", "generate_compaction_prompt_llm_complete", {
1288
+ session_id: snapshot.sessionID,
1289
+ duration_ms: llmDuration,
1290
+ exit_code: result.exitCode,
1291
+ stdout_length: result.stdout.length,
1292
+ stderr_length: result.stderr.length,
1293
+ stderr_preview: result.stderr.substring(0, 500),
1294
+ stdout_preview: result.stdout.substring(0, 500),
1295
+ });
1136
1296
 
1137
1297
  if (result.exitCode !== 0) {
1138
- console.error(
1139
- "[Swarm Compaction] opencode run failed:",
1140
- result.stderr,
1141
- );
1298
+ logCompaction("error", "generate_compaction_prompt_llm_failed", {
1299
+ session_id: snapshot.sessionID,
1300
+ exit_code: result.exitCode,
1301
+ stderr: result.stderr,
1302
+ stdout: result.stdout,
1303
+ duration_ms: llmDuration,
1304
+ });
1142
1305
  return null;
1143
1306
  }
1144
1307
 
1145
1308
  // Extract the prompt from stdout (LLM may wrap in markdown)
1146
1309
  const prompt = result.stdout.trim();
1310
+
1311
+ const totalDuration = Date.now() - startTime;
1312
+ logCompaction("debug", "generate_compaction_prompt_success", {
1313
+ session_id: snapshot.sessionID,
1314
+ total_duration_ms: totalDuration,
1315
+ llm_duration_ms: llmDuration,
1316
+ prompt_length: prompt.length,
1317
+ prompt_preview: prompt.substring(0, 500),
1318
+ prompt_has_content: prompt.length > 0,
1319
+ });
1320
+
1147
1321
  return prompt.length > 0 ? prompt : null;
1148
1322
  } catch (err) {
1149
- console.error("[Swarm Compaction] LLM generation failed:", err);
1323
+ const totalDuration = Date.now() - startTime;
1324
+ logCompaction("error", "generate_compaction_prompt_exception", {
1325
+ session_id: snapshot.sessionID,
1326
+ error: err instanceof Error ? err.message : String(err),
1327
+ stack: err instanceof Error ? err.stack : undefined,
1328
+ duration_ms: totalDuration,
1329
+ });
1150
1330
  return null;
1151
1331
  }
1152
1332
  }
1153
1333
 
1334
+ /**
1335
+ * Session message scan result
1336
+ */
1337
+ interface SessionScanResult {
1338
+ messageCount: number;
1339
+ toolCalls: Array<{
1340
+ toolName: string;
1341
+ args: Record<string, unknown>;
1342
+ output?: string;
1343
+ }>;
1344
+ swarmDetected: boolean;
1345
+ reasons: string[];
1346
+ }
1347
+
1348
+ /**
1349
+ * Scan session messages for swarm tool calls
1350
+ *
1351
+ * Uses SDK client to fetch messages and look for swarm activity.
1352
+ * This can detect swarm work even if no cells exist yet.
1353
+ */
1354
+ async function scanSessionMessages(sessionID: string): Promise<SessionScanResult> {
1355
+ const startTime = Date.now();
1356
+ const result: SessionScanResult = {
1357
+ messageCount: 0,
1358
+ toolCalls: [],
1359
+ swarmDetected: false,
1360
+ reasons: [],
1361
+ };
1362
+
1363
+ logCompaction("debug", "session_scan_start", {
1364
+ session_id: sessionID,
1365
+ has_sdk_client: !!sdkClient,
1366
+ });
1367
+
1368
+ if (!sdkClient) {
1369
+ logCompaction("warn", "session_scan_no_sdk_client", {
1370
+ session_id: sessionID,
1371
+ });
1372
+ return result;
1373
+ }
1374
+
1375
+ try {
1376
+ // Fetch session messages
1377
+ const messagesStart = Date.now();
1378
+ const rawResponse = await sdkClient.session.messages({ path: { id: sessionID } });
1379
+ const messagesDuration = Date.now() - messagesStart;
1380
+
1381
+ // Log the RAW response to understand its shape
1382
+ logCompaction("debug", "session_scan_raw_response", {
1383
+ session_id: sessionID,
1384
+ response_type: typeof rawResponse,
1385
+ is_array: Array.isArray(rawResponse),
1386
+ is_null: rawResponse === null,
1387
+ is_undefined: rawResponse === undefined,
1388
+ keys: rawResponse && typeof rawResponse === 'object' ? Object.keys(rawResponse) : [],
1389
+ raw_preview: JSON.stringify(rawResponse)?.slice(0, 500),
1390
+ });
1391
+
1392
+ // The response might be wrapped - check common patterns
1393
+ const messages = Array.isArray(rawResponse)
1394
+ ? rawResponse
1395
+ : rawResponse?.data
1396
+ ? rawResponse.data
1397
+ : rawResponse?.messages
1398
+ ? rawResponse.messages
1399
+ : rawResponse?.items
1400
+ ? rawResponse.items
1401
+ : [];
1402
+
1403
+ result.messageCount = messages?.length ?? 0;
1404
+
1405
+ logCompaction("debug", "session_scan_messages_fetched", {
1406
+ session_id: sessionID,
1407
+ duration_ms: messagesDuration,
1408
+ message_count: result.messageCount,
1409
+ extraction_method: Array.isArray(rawResponse) ? 'direct_array' : rawResponse?.data ? 'data_field' : rawResponse?.messages ? 'messages_field' : rawResponse?.items ? 'items_field' : 'fallback_empty',
1410
+ });
1411
+
1412
+ if (!Array.isArray(messages) || messages.length === 0) {
1413
+ logCompaction("debug", "session_scan_no_messages", {
1414
+ session_id: sessionID,
1415
+ });
1416
+ return result;
1417
+ }
1418
+
1419
+ // Swarm-related tool patterns
1420
+ const swarmTools = [
1421
+ // High confidence - active swarm coordination
1422
+ "hive_create_epic",
1423
+ "swarm_decompose",
1424
+ "swarm_spawn_subtask",
1425
+ "swarm_complete",
1426
+ "swarmmail_init",
1427
+ "swarmmail_reserve",
1428
+ // Medium confidence - swarm activity
1429
+ "hive_start",
1430
+ "hive_close",
1431
+ "swarm_status",
1432
+ "swarm_progress",
1433
+ "swarmmail_send",
1434
+ // Low confidence - possible swarm
1435
+ "hive_create",
1436
+ "hive_query",
1437
+ ];
1438
+
1439
+ const highConfidenceTools = new Set([
1440
+ "hive_create_epic",
1441
+ "swarm_decompose",
1442
+ "swarm_spawn_subtask",
1443
+ "swarmmail_init",
1444
+ "swarmmail_reserve",
1445
+ ]);
1446
+
1447
+ // Scan messages for tool calls
1448
+ let swarmToolCount = 0;
1449
+ let highConfidenceCount = 0;
1450
+
1451
+ // Debug: collect part types to understand message structure
1452
+ const partTypeCounts: Record<string, number> = {};
1453
+ let messagesWithParts = 0;
1454
+ let messagesWithoutParts = 0;
1455
+ let samplePartTypes: string[] = [];
1456
+
1457
+ for (const message of messages) {
1458
+ if (!message.parts || !Array.isArray(message.parts)) {
1459
+ messagesWithoutParts++;
1460
+ continue;
1461
+ }
1462
+ messagesWithParts++;
1463
+
1464
+ for (const part of message.parts) {
1465
+ const partType = part.type || "unknown";
1466
+ partTypeCounts[partType] = (partTypeCounts[partType] || 0) + 1;
1467
+
1468
+ // Collect first 10 unique part types for debugging
1469
+ if (samplePartTypes.length < 10 && !samplePartTypes.includes(partType)) {
1470
+ samplePartTypes.push(partType);
1471
+ }
1472
+
1473
+ // Check if this is a tool call part
1474
+ // OpenCode SDK: ToolPart has type="tool", tool=<string name>, state={...}
1475
+ if (part.type === "tool") {
1476
+ const toolPart = part as ToolPart;
1477
+ const toolName = toolPart.tool; // tool name is a string directly
1478
+
1479
+ if (toolName && swarmTools.includes(toolName)) {
1480
+ swarmToolCount++;
1481
+
1482
+ if (highConfidenceTools.has(toolName)) {
1483
+ highConfidenceCount++;
1484
+ }
1485
+
1486
+ // Extract args/output from state if available
1487
+ const state = toolPart.state;
1488
+ const args = state && "input" in state ? state.input : {};
1489
+ const output = state && "output" in state ? state.output : undefined;
1490
+
1491
+ result.toolCalls.push({
1492
+ toolName,
1493
+ args,
1494
+ output,
1495
+ });
1496
+
1497
+ logCompaction("debug", "session_scan_tool_found", {
1498
+ session_id: sessionID,
1499
+ tool_name: toolName,
1500
+ is_high_confidence: highConfidenceTools.has(toolName),
1501
+ });
1502
+ }
1503
+ }
1504
+ }
1505
+ }
1506
+
1507
+ // Determine if swarm detected based on tool calls
1508
+ if (highConfidenceCount > 0) {
1509
+ result.swarmDetected = true;
1510
+ result.reasons.push(`${highConfidenceCount} high-confidence swarm tools (${Array.from(new Set(result.toolCalls.filter(tc => highConfidenceTools.has(tc.toolName)).map(tc => tc.toolName))).join(", ")})`);
1511
+ }
1512
+
1513
+ if (swarmToolCount > 0 && !result.swarmDetected) {
1514
+ result.swarmDetected = true;
1515
+ result.reasons.push(`${swarmToolCount} swarm-related tools used`);
1516
+ }
1517
+
1518
+ const totalDuration = Date.now() - startTime;
1519
+
1520
+ // Debug: log part type distribution to understand message structure
1521
+ logCompaction("debug", "session_scan_part_types", {
1522
+ session_id: sessionID,
1523
+ messages_with_parts: messagesWithParts,
1524
+ messages_without_parts: messagesWithoutParts,
1525
+ part_type_counts: partTypeCounts,
1526
+ sample_part_types: samplePartTypes,
1527
+ });
1528
+
1529
+ logCompaction("info", "session_scan_complete", {
1530
+ session_id: sessionID,
1531
+ duration_ms: totalDuration,
1532
+ message_count: result.messageCount,
1533
+ tool_call_count: result.toolCalls.length,
1534
+ swarm_tool_count: swarmToolCount,
1535
+ high_confidence_count: highConfidenceCount,
1536
+ swarm_detected: result.swarmDetected,
1537
+ reasons: result.reasons,
1538
+ unique_tools: Array.from(new Set(result.toolCalls.map(tc => tc.toolName))),
1539
+ });
1540
+
1541
+ return result;
1542
+ } catch (err) {
1543
+ const totalDuration = Date.now() - startTime;
1544
+ logCompaction("error", "session_scan_exception", {
1545
+ session_id: sessionID,
1546
+ error: err instanceof Error ? err.message : String(err),
1547
+ stack: err instanceof Error ? err.stack : undefined,
1548
+ duration_ms: totalDuration,
1549
+ });
1550
+ return result;
1551
+ }
1552
+ }
1553
+
1154
1554
  /**
1155
1555
  * Check for swarm sign - evidence a swarm passed through
1156
1556
  *
@@ -1164,37 +1564,90 @@ Keep the prompt concise but actionable. Use actual data from the snapshot, not p
1164
1564
  * False negative = lost swarm (high cost)
1165
1565
  */
1166
1566
  async function detectSwarm(): Promise<SwarmDetection> {
1567
+ const startTime = Date.now();
1167
1568
  const reasons: string[] = [];
1168
1569
  let highConfidence = false;
1169
1570
  let mediumConfidence = false;
1170
1571
  let lowConfidence = false;
1171
1572
 
1573
+ logCompaction("debug", "detect_swarm_start", {
1574
+ project_directory: projectDirectory,
1575
+ cwd: process.cwd(),
1576
+ });
1577
+
1172
1578
  try {
1173
- const result = await new Promise<{ exitCode: number; stdout: string }>(
1579
+ const cliStart = Date.now();
1580
+ const result = await new Promise<{ exitCode: number; stdout: string; stderr: string }>(
1174
1581
  (resolve) => {
1175
1582
  // Use swarm tool to query beads
1176
1583
  const proc = spawn(SWARM_CLI, ["tool", "hive_query"], {
1584
+ cwd: projectDirectory,
1177
1585
  stdio: ["ignore", "pipe", "pipe"],
1178
1586
  });
1179
1587
  let stdout = "";
1588
+ let stderr = "";
1180
1589
  proc.stdout.on("data", (d) => {
1181
1590
  stdout += d;
1182
1591
  });
1592
+ proc.stderr.on("data", (d) => {
1593
+ stderr += d;
1594
+ });
1183
1595
  proc.on("close", (exitCode) =>
1184
- resolve({ exitCode: exitCode ?? 1, stdout }),
1596
+ resolve({ exitCode: exitCode ?? 1, stdout, stderr }),
1185
1597
  );
1186
1598
  },
1187
1599
  );
1600
+ const cliDuration = Date.now() - cliStart;
1601
+
1602
+ logCompaction("debug", "detect_swarm_cli_complete", {
1603
+ duration_ms: cliDuration,
1604
+ exit_code: result.exitCode,
1605
+ stdout_length: result.stdout.length,
1606
+ stderr_length: result.stderr.length,
1607
+ stderr_preview: result.stderr.substring(0, 200),
1608
+ });
1188
1609
 
1189
1610
  if (result.exitCode !== 0) {
1611
+ logCompaction("warn", "detect_swarm_cli_failed", {
1612
+ exit_code: result.exitCode,
1613
+ stderr: result.stderr,
1614
+ });
1190
1615
  return { detected: false, confidence: "none", reasons: ["hive_query failed"] };
1191
1616
  }
1192
1617
 
1193
- const cells = JSON.parse(result.stdout);
1618
+ let cells: any[];
1619
+ try {
1620
+ cells = JSON.parse(result.stdout);
1621
+ } catch (parseErr) {
1622
+ logCompaction("error", "detect_swarm_parse_failed", {
1623
+ error: parseErr instanceof Error ? parseErr.message : String(parseErr),
1624
+ stdout_preview: result.stdout.substring(0, 500),
1625
+ });
1626
+ return { detected: false, confidence: "none", reasons: ["hive_query parse failed"] };
1627
+ }
1628
+
1194
1629
  if (!Array.isArray(cells) || cells.length === 0) {
1630
+ logCompaction("debug", "detect_swarm_no_cells", {
1631
+ is_array: Array.isArray(cells),
1632
+ length: cells?.length ?? 0,
1633
+ });
1195
1634
  return { detected: false, confidence: "none", reasons: ["no cells found"] };
1196
1635
  }
1197
1636
 
1637
+ // Log ALL cells for debugging
1638
+ logCompaction("debug", "detect_swarm_cells_found", {
1639
+ total_cells: cells.length,
1640
+ cells: cells.map((c: any) => ({
1641
+ id: c.id,
1642
+ title: c.title,
1643
+ type: c.type,
1644
+ status: c.status,
1645
+ parent_id: c.parent_id,
1646
+ updated_at: c.updated_at,
1647
+ created_at: c.created_at,
1648
+ })),
1649
+ });
1650
+
1198
1651
  // HIGH: Any in_progress cells
1199
1652
  const inProgress = cells.filter(
1200
1653
  (c: { status: string }) => c.status === "in_progress"
@@ -1202,6 +1655,10 @@ async function detectSwarm(): Promise<SwarmDetection> {
1202
1655
  if (inProgress.length > 0) {
1203
1656
  highConfidence = true;
1204
1657
  reasons.push(`${inProgress.length} cells in_progress`);
1658
+ logCompaction("debug", "detect_swarm_in_progress", {
1659
+ count: inProgress.length,
1660
+ cells: inProgress.map((c: any) => ({ id: c.id, title: c.title })),
1661
+ });
1205
1662
  }
1206
1663
 
1207
1664
  // MEDIUM: Open subtasks (cells with parent_id)
@@ -1212,6 +1669,10 @@ async function detectSwarm(): Promise<SwarmDetection> {
1212
1669
  if (subtasks.length > 0) {
1213
1670
  mediumConfidence = true;
1214
1671
  reasons.push(`${subtasks.length} open subtasks`);
1672
+ logCompaction("debug", "detect_swarm_open_subtasks", {
1673
+ count: subtasks.length,
1674
+ cells: subtasks.map((c: any) => ({ id: c.id, title: c.title, parent_id: c.parent_id })),
1675
+ });
1215
1676
  }
1216
1677
 
1217
1678
  // MEDIUM: Unclosed epics
@@ -1222,6 +1683,10 @@ async function detectSwarm(): Promise<SwarmDetection> {
1222
1683
  if (openEpics.length > 0) {
1223
1684
  mediumConfidence = true;
1224
1685
  reasons.push(`${openEpics.length} unclosed epics`);
1686
+ logCompaction("debug", "detect_swarm_open_epics", {
1687
+ count: openEpics.length,
1688
+ cells: openEpics.map((c: any) => ({ id: c.id, title: c.title, status: c.status })),
1689
+ });
1225
1690
  }
1226
1691
 
1227
1692
  // MEDIUM: Recently updated cells (last hour)
@@ -1232,6 +1697,16 @@ async function detectSwarm(): Promise<SwarmDetection> {
1232
1697
  if (recentCells.length > 0) {
1233
1698
  mediumConfidence = true;
1234
1699
  reasons.push(`${recentCells.length} cells updated in last hour`);
1700
+ logCompaction("debug", "detect_swarm_recent_cells", {
1701
+ count: recentCells.length,
1702
+ one_hour_ago: oneHourAgo,
1703
+ cells: recentCells.map((c: any) => ({
1704
+ id: c.id,
1705
+ title: c.title,
1706
+ updated_at: c.updated_at,
1707
+ age_minutes: Math.round((Date.now() - c.updated_at) / 60000),
1708
+ })),
1709
+ });
1235
1710
  }
1236
1711
 
1237
1712
  // LOW: Any cells exist at all
@@ -1239,10 +1714,14 @@ async function detectSwarm(): Promise<SwarmDetection> {
1239
1714
  lowConfidence = true;
1240
1715
  reasons.push(`${cells.length} total cells in hive`);
1241
1716
  }
1242
- } catch {
1717
+ } catch (err) {
1243
1718
  // Detection failed, use fallback
1244
1719
  lowConfidence = true;
1245
1720
  reasons.push("Detection error, using fallback");
1721
+ logCompaction("error", "detect_swarm_exception", {
1722
+ error: err instanceof Error ? err.message : String(err),
1723
+ stack: err instanceof Error ? err.stack : undefined,
1724
+ });
1246
1725
  }
1247
1726
 
1248
1727
  // Determine overall confidence
@@ -1257,6 +1736,18 @@ async function detectSwarm(): Promise<SwarmDetection> {
1257
1736
  confidence = "none";
1258
1737
  }
1259
1738
 
1739
+ const totalDuration = Date.now() - startTime;
1740
+ logCompaction("debug", "detect_swarm_complete", {
1741
+ duration_ms: totalDuration,
1742
+ confidence,
1743
+ detected: confidence !== "none",
1744
+ reason_count: reasons.length,
1745
+ reasons,
1746
+ high_confidence: highConfidence,
1747
+ medium_confidence: mediumConfidence,
1748
+ low_confidence: lowConfidence,
1749
+ });
1750
+
1260
1751
  return {
1261
1752
  detected: confidence !== "none",
1262
1753
  confidence,
@@ -1383,13 +1874,18 @@ type ExtendedHooks = Hooks & {
1383
1874
  ) => Promise<void>;
1384
1875
  };
1385
1876
 
1386
- export const SwarmPlugin: Plugin = async (
1877
+ // NOTE: Only default export - named exports cause double registration!
1878
+ // OpenCode's plugin loader calls ALL exports as functions.
1879
+ const SwarmPlugin: Plugin = async (
1387
1880
  input: PluginInput,
1388
1881
  ): Promise<ExtendedHooks> => {
1389
1882
  // CRITICAL: Set project directory from OpenCode input
1390
1883
  // Without this, CLI uses wrong database path
1391
1884
  projectDirectory = input.directory;
1392
1885
 
1886
+ // Store SDK client for session message scanning during compaction
1887
+ sdkClient = input.client;
1888
+
1393
1889
  return {
1394
1890
  tool: {
1395
1891
  // Beads
@@ -1458,55 +1954,272 @@ export const SwarmPlugin: Plugin = async (
1458
1954
  input: { sessionID: string },
1459
1955
  output: CompactionOutput,
1460
1956
  ) => {
1957
+ const startTime = Date.now();
1958
+
1959
+ // =======================================================================
1960
+ // LOG: Compaction hook invoked - capture EVERYTHING we receive
1961
+ // =======================================================================
1962
+ logCompaction("info", "compaction_hook_invoked", {
1963
+ session_id: input.sessionID,
1964
+ project_directory: projectDirectory,
1965
+ input_keys: Object.keys(input),
1966
+ input_full: JSON.parse(JSON.stringify(input)), // Deep clone for logging
1967
+ output_keys: Object.keys(output),
1968
+ output_context_count: output.context?.length ?? 0,
1969
+ output_has_prompt_field: "prompt" in output,
1970
+ output_initial_state: {
1971
+ context: output.context,
1972
+ prompt: (output as any).prompt,
1973
+ },
1974
+ env: {
1975
+ OPENCODE_SESSION_ID: process.env.OPENCODE_SESSION_ID,
1976
+ OPENCODE_MESSAGE_ID: process.env.OPENCODE_MESSAGE_ID,
1977
+ OPENCODE_AGENT: process.env.OPENCODE_AGENT,
1978
+ OPENCODE_LITE_MODEL: process.env.OPENCODE_LITE_MODEL,
1979
+ SWARM_PROJECT_DIR: process.env.SWARM_PROJECT_DIR,
1980
+ },
1981
+ cwd: process.cwd(),
1982
+ timestamp: new Date().toISOString(),
1983
+ });
1984
+
1985
+ // =======================================================================
1986
+ // STEP 1: Scan session messages for swarm tool calls
1987
+ // =======================================================================
1988
+ const sessionScanStart = Date.now();
1989
+ const sessionScan = await scanSessionMessages(input.sessionID);
1990
+ const sessionScanDuration = Date.now() - sessionScanStart;
1991
+
1992
+ logCompaction("info", "session_scan_results", {
1993
+ session_id: input.sessionID,
1994
+ duration_ms: sessionScanDuration,
1995
+ message_count: sessionScan.messageCount,
1996
+ tool_call_count: sessionScan.toolCalls.length,
1997
+ swarm_detected_from_messages: sessionScan.swarmDetected,
1998
+ reasons: sessionScan.reasons,
1999
+ });
2000
+
2001
+ // =======================================================================
2002
+ // STEP 2: Detect swarm state from hive cells
2003
+ // =======================================================================
2004
+ const detectionStart = Date.now();
1461
2005
  const detection = await detectSwarm();
2006
+ const detectionDuration = Date.now() - detectionStart;
2007
+
2008
+ logCompaction("info", "swarm_detection_complete", {
2009
+ session_id: input.sessionID,
2010
+ duration_ms: detectionDuration,
2011
+ detected: detection.detected,
2012
+ confidence: detection.confidence,
2013
+ reasons: detection.reasons,
2014
+ reason_count: detection.reasons.length,
2015
+ });
2016
+
2017
+ // =======================================================================
2018
+ // STEP 3: Merge session scan with hive detection for final confidence
2019
+ // =======================================================================
2020
+ // If session messages show high-confidence swarm tools, boost confidence
2021
+ if (sessionScan.swarmDetected && sessionScan.reasons.some(r => r.includes("high-confidence"))) {
2022
+ if (detection.confidence === "none" || detection.confidence === "low") {
2023
+ detection.confidence = "high";
2024
+ detection.detected = true;
2025
+ detection.reasons.push(...sessionScan.reasons);
2026
+
2027
+ logCompaction("info", "confidence_boost_from_session_scan", {
2028
+ session_id: input.sessionID,
2029
+ original_confidence: detection.confidence,
2030
+ boosted_to: "high",
2031
+ session_reasons: sessionScan.reasons,
2032
+ });
2033
+ }
2034
+ } else if (sessionScan.swarmDetected) {
2035
+ // Medium boost for any swarm tools found
2036
+ if (detection.confidence === "none") {
2037
+ detection.confidence = "medium";
2038
+ detection.detected = true;
2039
+ detection.reasons.push(...sessionScan.reasons);
2040
+
2041
+ logCompaction("info", "confidence_boost_from_session_scan", {
2042
+ session_id: input.sessionID,
2043
+ original_confidence: "none",
2044
+ boosted_to: "medium",
2045
+ session_reasons: sessionScan.reasons,
2046
+ });
2047
+ } else if (detection.confidence === "low") {
2048
+ detection.confidence = "medium";
2049
+ detection.reasons.push(...sessionScan.reasons);
2050
+
2051
+ logCompaction("info", "confidence_boost_from_session_scan", {
2052
+ session_id: input.sessionID,
2053
+ original_confidence: "low",
2054
+ boosted_to: "medium",
2055
+ session_reasons: sessionScan.reasons,
2056
+ });
2057
+ }
2058
+ }
2059
+
2060
+ logCompaction("info", "final_swarm_detection", {
2061
+ session_id: input.sessionID,
2062
+ confidence: detection.confidence,
2063
+ detected: detection.detected,
2064
+ combined_reasons: detection.reasons,
2065
+ message_scan_contributed: sessionScan.swarmDetected,
2066
+ });
1462
2067
 
1463
2068
  if (detection.confidence === "high" || detection.confidence === "medium") {
1464
2069
  // Definite or probable swarm - try LLM-powered compaction
2070
+ logCompaction("info", "swarm_detected_attempting_llm", {
2071
+ session_id: input.sessionID,
2072
+ confidence: detection.confidence,
2073
+ reasons: detection.reasons,
2074
+ });
2075
+
1465
2076
  try {
1466
2077
  // Level 1: Query actual state
2078
+ const queryStart = Date.now();
1467
2079
  const snapshot = await querySwarmState(input.sessionID);
2080
+ const queryDuration = Date.now() - queryStart;
2081
+
2082
+ logCompaction("info", "swarm_state_queried", {
2083
+ session_id: input.sessionID,
2084
+ duration_ms: queryDuration,
2085
+ has_epic: !!snapshot.epic,
2086
+ epic_id: snapshot.epic?.id,
2087
+ epic_title: snapshot.epic?.title,
2088
+ epic_status: snapshot.epic?.status,
2089
+ subtask_count: snapshot.epic?.subtasks?.length ?? 0,
2090
+ subtasks: snapshot.epic?.subtasks?.map(s => ({
2091
+ id: s.id,
2092
+ title: s.title,
2093
+ status: s.status,
2094
+ file_count: s.files?.length ?? 0,
2095
+ })),
2096
+ message_count: snapshot.messages?.length ?? 0,
2097
+ reservation_count: snapshot.reservations?.length ?? 0,
2098
+ detection_confidence: snapshot.detection.confidence,
2099
+ detection_reasons: snapshot.detection.reasons,
2100
+ full_snapshot: snapshot, // Log the entire snapshot
2101
+ });
1468
2102
 
1469
2103
  // Level 2: Generate prompt with LLM
2104
+ const llmStart = Date.now();
1470
2105
  const llmPrompt = await generateCompactionPrompt(snapshot);
2106
+ const llmDuration = Date.now() - llmStart;
2107
+
2108
+ logCompaction("info", "llm_generation_complete", {
2109
+ session_id: input.sessionID,
2110
+ duration_ms: llmDuration,
2111
+ success: !!llmPrompt,
2112
+ prompt_length: llmPrompt?.length ?? 0,
2113
+ prompt_preview: llmPrompt?.substring(0, 500),
2114
+ });
1471
2115
 
1472
2116
  if (llmPrompt) {
1473
2117
  // SUCCESS: Use LLM-generated prompt
1474
2118
  const header = `[Swarm compaction: LLM-generated, ${detection.reasons.join(", ")}]\n\n`;
2119
+ const fullContent = header + llmPrompt;
1475
2120
 
1476
2121
  // Progressive enhancement: use new API if available
1477
2122
  if ("prompt" in output) {
1478
- output.prompt = header + llmPrompt;
2123
+ output.prompt = fullContent;
2124
+ logCompaction("info", "context_injected_via_prompt_api", {
2125
+ session_id: input.sessionID,
2126
+ content_length: fullContent.length,
2127
+ method: "output.prompt",
2128
+ });
1479
2129
  } else {
1480
- output.context.push(header + llmPrompt);
2130
+ output.context.push(fullContent);
2131
+ logCompaction("info", "context_injected_via_context_array", {
2132
+ session_id: input.sessionID,
2133
+ content_length: fullContent.length,
2134
+ method: "output.context.push",
2135
+ context_count_after: output.context.length,
2136
+ });
1481
2137
  }
1482
2138
 
1483
- console.log(
1484
- "[Swarm Compaction] Using LLM-generated continuation prompt",
1485
- );
2139
+ const totalDuration = Date.now() - startTime;
2140
+ logCompaction("info", "compaction_complete_llm_success", {
2141
+ session_id: input.sessionID,
2142
+ total_duration_ms: totalDuration,
2143
+ detection_duration_ms: detectionDuration,
2144
+ query_duration_ms: queryDuration,
2145
+ llm_duration_ms: llmDuration,
2146
+ confidence: detection.confidence,
2147
+ context_type: "llm_generated",
2148
+ content_length: fullContent.length,
2149
+ });
1486
2150
  return;
1487
2151
  }
1488
2152
 
1489
2153
  // LLM failed, fall through to static prompt
1490
- console.log(
1491
- "[Swarm Compaction] LLM generation returned null, using static prompt",
1492
- );
2154
+ logCompaction("warn", "llm_generation_returned_null", {
2155
+ session_id: input.sessionID,
2156
+ llm_duration_ms: llmDuration,
2157
+ falling_back_to: "static_prompt",
2158
+ });
1493
2159
  } catch (err) {
1494
2160
  // LLM failed, fall through to static prompt
1495
- console.error(
1496
- "[Swarm Compaction] LLM generation failed, using static prompt:",
1497
- err,
1498
- );
2161
+ logCompaction("error", "llm_generation_failed", {
2162
+ session_id: input.sessionID,
2163
+ error: err instanceof Error ? err.message : String(err),
2164
+ error_stack: err instanceof Error ? err.stack : undefined,
2165
+ falling_back_to: "static_prompt",
2166
+ });
1499
2167
  }
1500
2168
 
1501
2169
  // Level 3: Fall back to static context
1502
2170
  const header = `[Swarm detected: ${detection.reasons.join(", ")}]\n\n`;
1503
- output.context.push(header + SWARM_COMPACTION_CONTEXT);
2171
+ const staticContent = header + SWARM_COMPACTION_CONTEXT;
2172
+ output.context.push(staticContent);
2173
+
2174
+ const totalDuration = Date.now() - startTime;
2175
+ logCompaction("info", "compaction_complete_static_fallback", {
2176
+ session_id: input.sessionID,
2177
+ total_duration_ms: totalDuration,
2178
+ confidence: detection.confidence,
2179
+ context_type: "static_swarm_context",
2180
+ content_length: staticContent.length,
2181
+ context_count_after: output.context.length,
2182
+ });
1504
2183
  } else if (detection.confidence === "low") {
1505
2184
  // Level 4: Possible swarm - inject fallback detection prompt
1506
2185
  const header = `[Possible swarm: ${detection.reasons.join(", ")}]\n\n`;
1507
- output.context.push(header + SWARM_DETECTION_FALLBACK);
2186
+ const fallbackContent = header + SWARM_DETECTION_FALLBACK;
2187
+ output.context.push(fallbackContent);
2188
+
2189
+ const totalDuration = Date.now() - startTime;
2190
+ logCompaction("info", "compaction_complete_detection_fallback", {
2191
+ session_id: input.sessionID,
2192
+ total_duration_ms: totalDuration,
2193
+ confidence: detection.confidence,
2194
+ context_type: "detection_fallback",
2195
+ content_length: fallbackContent.length,
2196
+ context_count_after: output.context.length,
2197
+ reasons: detection.reasons,
2198
+ });
2199
+ } else {
2200
+ // Level 5: confidence === "none" - no injection, probably not a swarm
2201
+ const totalDuration = Date.now() - startTime;
2202
+ logCompaction("info", "compaction_complete_no_swarm", {
2203
+ session_id: input.sessionID,
2204
+ total_duration_ms: totalDuration,
2205
+ confidence: detection.confidence,
2206
+ context_type: "none",
2207
+ reasons: detection.reasons,
2208
+ context_count_unchanged: output.context.length,
2209
+ });
1508
2210
  }
1509
- // Level 5: confidence === "none" - no injection, probably not a swarm
2211
+
2212
+ // =======================================================================
2213
+ // LOG: Final output state
2214
+ // =======================================================================
2215
+ logCompaction("debug", "compaction_hook_complete_final_state", {
2216
+ session_id: input.sessionID,
2217
+ output_context_count: output.context?.length ?? 0,
2218
+ output_context_lengths: output.context?.map(c => c.length) ?? [],
2219
+ output_has_prompt: !!(output as any).prompt,
2220
+ output_prompt_length: (output as any).prompt?.length ?? 0,
2221
+ total_duration_ms: Date.now() - startTime,
2222
+ });
1510
2223
  },
1511
2224
  };
1512
2225
  };