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.
- package/.hive/issues.jsonl +4 -4
- package/.hive/memories.jsonl +274 -1
- package/.turbo/turbo-build.log +4 -4
- package/.turbo/turbo-test.log +307 -307
- package/CHANGELOG.md +71 -0
- package/bin/swarm.ts +234 -179
- package/dist/compaction-hook.d.ts +54 -4
- package/dist/compaction-hook.d.ts.map +1 -1
- package/dist/eval-capture.d.ts +122 -17
- package/dist/eval-capture.d.ts.map +1 -1
- package/dist/index.d.ts +1 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1278 -619
- package/dist/planning-guardrails.d.ts +121 -0
- package/dist/planning-guardrails.d.ts.map +1 -1
- package/dist/plugin.d.ts +9 -9
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +1283 -329
- package/dist/schemas/task.d.ts +0 -1
- package/dist/schemas/task.d.ts.map +1 -1
- package/dist/swarm-decompose.d.ts +0 -8
- package/dist/swarm-decompose.d.ts.map +1 -1
- package/dist/swarm-orchestrate.d.ts.map +1 -1
- package/dist/swarm-prompts.d.ts +0 -4
- package/dist/swarm-prompts.d.ts.map +1 -1
- package/dist/swarm-review.d.ts.map +1 -1
- package/dist/swarm.d.ts +0 -6
- package/dist/swarm.d.ts.map +1 -1
- package/evals/README.md +38 -0
- package/evals/coordinator-session.eval.ts +154 -0
- package/evals/fixtures/coordinator-sessions.ts +328 -0
- package/evals/lib/data-loader.ts +69 -0
- package/evals/scorers/coordinator-discipline.evalite-test.ts +536 -0
- package/evals/scorers/coordinator-discipline.ts +315 -0
- package/evals/scorers/index.ts +12 -0
- package/examples/plugin-wrapper-template.ts +303 -4
- package/package.json +2 -2
- package/src/compaction-hook.test.ts +8 -1
- package/src/compaction-hook.ts +31 -21
- package/src/eval-capture.test.ts +390 -0
- package/src/eval-capture.ts +163 -4
- package/src/index.ts +68 -1
- package/src/planning-guardrails.test.ts +387 -2
- package/src/planning-guardrails.ts +289 -0
- package/src/plugin.ts +10 -10
- package/src/swarm-decompose.ts +20 -0
- package/src/swarm-orchestrate.ts +44 -0
- package/src/swarm-prompts.ts +20 -0
- package/src/swarm-review.ts +41 -0
package/src/eval-capture.ts
CHANGED
|
@@ -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)
|
|
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")
|
|
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
|
-
|
|
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 {
|
|
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
|
});
|