opencode-swarm-plugin 0.36.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 (49) 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 +71 -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 +303 -4
  37. package/package.json +2 -2
  38. package/src/compaction-hook.test.ts +8 -1
  39. package/src/compaction-hook.ts +31 -21
  40. package/src/eval-capture.test.ts +390 -0
  41. package/src/eval-capture.ts +163 -4
  42. package/src/index.ts +68 -1
  43. package/src/planning-guardrails.test.ts +387 -2
  44. package/src/planning-guardrails.ts +289 -0
  45. package/src/plugin.ts +10 -10
  46. package/src/swarm-decompose.ts +20 -0
  47. package/src/swarm-orchestrate.ts +44 -0
  48. package/src/swarm-prompts.ts +20 -0
  49. package/src/swarm-review.ts +41 -0
@@ -9,12 +9,15 @@
9
9
  * 2. swarm_complete captures: outcome signals per subtask
10
10
  * 3. swarm_record_outcome captures: learning signals
11
11
  * 4. Human feedback (optional): accept/reject/modify
12
+ * 5. Coordinator events: decisions, violations, outcomes
13
+ * 6. Session capture: full coordinator session to ~/.config/swarm-tools/sessions/
12
14
  *
13
15
  * @module eval-capture
14
16
  */
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
15
20
  import { z } from "zod";
16
- import * as fs from "fs";
17
- import * as path from "path";
18
21
 
19
22
  // ============================================================================
20
23
  // Schemas
@@ -119,6 +122,67 @@ export type PartialEvalRecord = Partial<EvalRecord> & {
119
122
  task: string;
120
123
  };
121
124
 
125
+ /**
126
+ * Coordinator Event - captures coordinator decisions, violations, and outcomes
127
+ */
128
+ export const CoordinatorEventSchema = z.discriminatedUnion("event_type", [
129
+ // DECISION events
130
+ z.object({
131
+ session_id: z.string(),
132
+ epic_id: z.string(),
133
+ timestamp: z.string(),
134
+ event_type: z.literal("DECISION"),
135
+ decision_type: z.enum([
136
+ "strategy_selected",
137
+ "worker_spawned",
138
+ "review_completed",
139
+ "decomposition_complete",
140
+ ]),
141
+ payload: z.any(),
142
+ }),
143
+ // VIOLATION events
144
+ z.object({
145
+ session_id: z.string(),
146
+ epic_id: z.string(),
147
+ timestamp: z.string(),
148
+ event_type: z.literal("VIOLATION"),
149
+ violation_type: z.enum([
150
+ "coordinator_edited_file",
151
+ "coordinator_ran_tests",
152
+ "coordinator_reserved_files",
153
+ "no_worker_spawned",
154
+ ]),
155
+ payload: z.any(),
156
+ }),
157
+ // OUTCOME events
158
+ z.object({
159
+ session_id: z.string(),
160
+ epic_id: z.string(),
161
+ timestamp: z.string(),
162
+ event_type: z.literal("OUTCOME"),
163
+ outcome_type: z.enum([
164
+ "subtask_success",
165
+ "subtask_retry",
166
+ "subtask_failed",
167
+ "epic_complete",
168
+ ]),
169
+ payload: z.any(),
170
+ }),
171
+ ]);
172
+ export type CoordinatorEvent = z.infer<typeof CoordinatorEventSchema>;
173
+
174
+ /**
175
+ * Coordinator Session - wraps a full coordinator session
176
+ */
177
+ export const CoordinatorSessionSchema = z.object({
178
+ session_id: z.string(),
179
+ epic_id: z.string(),
180
+ start_time: z.string(),
181
+ end_time: z.string().optional(),
182
+ events: z.array(CoordinatorEventSchema),
183
+ });
184
+ export type CoordinatorSession = z.infer<typeof CoordinatorSessionSchema>;
185
+
122
186
  // ============================================================================
123
187
  // Storage
124
188
  // ============================================================================
@@ -155,7 +219,7 @@ export function appendEvalRecord(
155
219
  ): void {
156
220
  ensureEvalDataDir(projectPath);
157
221
  const evalPath = getEvalDataPath(projectPath);
158
- const line = JSON.stringify(record) + "\n";
222
+ const line = `${JSON.stringify(record)}\n`;
159
223
  fs.appendFileSync(evalPath, line, "utf-8");
160
224
  }
161
225
 
@@ -211,7 +275,7 @@ export function updateEvalRecord(
211
275
 
212
276
  // Rewrite the file
213
277
  const evalPath = getEvalDataPath(projectPath);
214
- const content = records.map((r) => JSON.stringify(r)).join("\n") + "\n";
278
+ const content = `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
215
279
  fs.writeFileSync(evalPath, content, "utf-8");
216
280
 
217
281
  return true;
@@ -484,3 +548,98 @@ export function getEvalDataStats(projectPath: string): {
484
548
  avgTimeBalance,
485
549
  };
486
550
  }
551
+
552
+ // ============================================================================
553
+ // Coordinator Session Capture
554
+ // ============================================================================
555
+
556
+ /**
557
+ * Get the session directory path
558
+ */
559
+ export function getSessionDir(): string {
560
+ return path.join(os.homedir(), ".config", "swarm-tools", "sessions");
561
+ }
562
+
563
+ /**
564
+ * Get the session file path for a session ID
565
+ */
566
+ export function getSessionPath(sessionId: string): string {
567
+ return path.join(getSessionDir(), `${sessionId}.jsonl`);
568
+ }
569
+
570
+ /**
571
+ * Ensure the session directory exists
572
+ */
573
+ export function ensureSessionDir(): void {
574
+ const sessionDir = getSessionDir();
575
+ if (!fs.existsSync(sessionDir)) {
576
+ fs.mkdirSync(sessionDir, { recursive: true });
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Capture a coordinator event to the session file
582
+ *
583
+ * Appends the event as a JSONL line to ~/.config/swarm-tools/sessions/{session_id}.jsonl
584
+ */
585
+ export function captureCoordinatorEvent(event: CoordinatorEvent): void {
586
+ // Validate event
587
+ CoordinatorEventSchema.parse(event);
588
+
589
+ // Ensure directory exists
590
+ ensureSessionDir();
591
+
592
+ // Append to session file
593
+ const sessionPath = getSessionPath(event.session_id);
594
+ const line = `${JSON.stringify(event)}\n`;
595
+ fs.appendFileSync(sessionPath, line, "utf-8");
596
+ }
597
+
598
+ /**
599
+ * Read all events from a session file
600
+ */
601
+ export function readSessionEvents(sessionId: string): CoordinatorEvent[] {
602
+ const sessionPath = getSessionPath(sessionId);
603
+ if (!fs.existsSync(sessionPath)) {
604
+ return [];
605
+ }
606
+
607
+ const content = fs.readFileSync(sessionPath, "utf-8");
608
+ const lines = content.trim().split("\n").filter(Boolean);
609
+
610
+ return lines.map((line) => {
611
+ const parsed = JSON.parse(line);
612
+ return CoordinatorEventSchema.parse(parsed);
613
+ });
614
+ }
615
+
616
+ /**
617
+ * Save a session - wraps all events in a CoordinatorSession structure
618
+ *
619
+ * Reads all events from the session file and wraps them in a session object.
620
+ * Returns null if the session file doesn't exist.
621
+ */
622
+ export function saveSession(params: {
623
+ session_id: string;
624
+ epic_id: string;
625
+ }): CoordinatorSession | null {
626
+ const events = readSessionEvents(params.session_id);
627
+ if (events.length === 0) {
628
+ return null;
629
+ }
630
+
631
+ // Get timestamps from events
632
+ const timestamps = events.map((e) => new Date(e.timestamp).getTime());
633
+ const startTime = new Date(Math.min(...timestamps)).toISOString();
634
+ const endTime = new Date(Math.max(...timestamps)).toISOString();
635
+
636
+ const session: CoordinatorSession = {
637
+ session_id: params.session_id,
638
+ epic_id: params.epic_id,
639
+ start_time: startTime,
640
+ end_time: endTime,
641
+ events,
642
+ };
643
+
644
+ return session;
645
+ }
package/src/index.ts CHANGED
@@ -57,6 +57,11 @@ 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";
61
66
  import { createCompactionHook } from "./compaction-hook";
62
67
 
@@ -78,7 +83,7 @@ import { createCompactionHook } from "./compaction-hook";
78
83
  * @param input - Plugin context from OpenCode
79
84
  * @returns Plugin hooks including tools, events, and tool execution hooks
80
85
  */
81
- export const SwarmPlugin: Plugin = async (
86
+ const SwarmPlugin: Plugin = async (
82
87
  input: PluginInput,
83
88
  ): Promise<Hooks> => {
84
89
  const { $, directory, client } = input;
@@ -190,6 +195,8 @@ export const SwarmPlugin: Plugin = async (
190
195
  *
191
196
  * Warns when agents are about to make planning mistakes:
192
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)
193
200
  */
194
201
  "tool.execute.before": async (input, output) => {
195
202
  const toolName = input.tool;
@@ -201,6 +208,36 @@ export const SwarmPlugin: Plugin = async (
201
208
  console.warn(`[swarm-plugin] ${analysis.warning}`);
202
209
  }
203
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
+ }
204
241
  },
205
242
 
206
243
  /**
@@ -258,6 +295,36 @@ export const SwarmPlugin: Plugin = async (
258
295
  await releaseReservations();
259
296
  }
260
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
+
261
328
  // Note: hive_sync should be called explicitly at session end
262
329
  // Auto-sync was removed because bd CLI is deprecated
263
330
  // The hive_sync tool handles flushing to JSONL and git commit/push
@@ -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
  });