claude-code-swarm 0.3.5 → 0.3.7

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 (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.claude-plugin/run-agent-inbox-mcp.sh +22 -3
  4. package/.gitattributes +3 -0
  5. package/.opentasks/config.json +9 -0
  6. package/.opentasks/graph.jsonl +0 -0
  7. package/e2e/helpers/opentasks-daemon.mjs +149 -0
  8. package/e2e/tier6-live-inbox-flow.test.mjs +938 -0
  9. package/e2e/tier7-hooks.test.mjs +992 -0
  10. package/e2e/tier7-minimem.test.mjs +461 -0
  11. package/e2e/tier7-opentasks.test.mjs +513 -0
  12. package/e2e/tier7-skilltree.test.mjs +506 -0
  13. package/e2e/vitest.config.e2e.mjs +1 -1
  14. package/package.json +6 -2
  15. package/references/agent-inbox/package-lock.json +2 -2
  16. package/references/agent-inbox/package.json +1 -1
  17. package/references/agent-inbox/src/index.ts +16 -2
  18. package/references/agent-inbox/src/ipc/ipc-server.ts +58 -0
  19. package/references/agent-inbox/src/mcp/mcp-proxy.ts +326 -0
  20. package/references/agent-inbox/src/types.ts +26 -0
  21. package/references/agent-inbox/test/ipc-new-commands.test.ts +200 -0
  22. package/references/agent-inbox/test/mcp-proxy.test.ts +191 -0
  23. package/references/minimem/package-lock.json +2 -2
  24. package/references/minimem/package.json +1 -1
  25. package/scripts/bootstrap.mjs +8 -1
  26. package/scripts/map-hook.mjs +6 -2
  27. package/scripts/map-sidecar.mjs +19 -0
  28. package/scripts/team-loader.mjs +15 -8
  29. package/skills/swarm/SKILL.md +16 -22
  30. package/src/__tests__/agent-generator.test.mjs +9 -10
  31. package/src/__tests__/context-output.test.mjs +13 -14
  32. package/src/__tests__/e2e-inbox-integration.test.mjs +732 -0
  33. package/src/__tests__/e2e-live-inbox.test.mjs +597 -0
  34. package/src/__tests__/inbox-integration.test.mjs +298 -0
  35. package/src/__tests__/integration.test.mjs +12 -11
  36. package/src/__tests__/skilltree-client.test.mjs +47 -1
  37. package/src/agent-generator.mjs +79 -88
  38. package/src/bootstrap.mjs +24 -3
  39. package/src/context-output.mjs +238 -64
  40. package/src/index.mjs +2 -0
  41. package/src/sidecar-server.mjs +30 -0
  42. package/src/skilltree-client.mjs +50 -5
@@ -0,0 +1,992 @@
1
+ /**
2
+ * Tier 7: Hook & Event Builder Integration Tests
3
+ *
4
+ * Tests the MAP event pipeline without LLM calls:
5
+ * 1. Pure event builder functions (buildSubagentSpawnCommand, etc.)
6
+ * 2. mapNativeTaskStatus status mapping
7
+ * 3. Sidecar IPC round-trip (bridge commands → mock MAP server)
8
+ * 4. Hook script integration (scripts/map-hook.mjs with crafted stdin)
9
+ * 5. Full pipeline (skill-tree → agent generation)
10
+ *
11
+ * No LLM calls — exercises pure computation, IPC, and subprocess hooks.
12
+ *
13
+ * Run:
14
+ * npx vitest run --config e2e/vitest.config.e2e.mjs e2e/tier7-hooks.test.mjs
15
+ */
16
+
17
+ import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
18
+ import fs from "fs";
19
+ import path from "path";
20
+ import { spawn } from "child_process";
21
+ import { fileURLToPath } from "url";
22
+ import { createWorkspace } from "./helpers/workspace.mjs";
23
+ import { MockMapServer } from "./helpers/map-mock-server.mjs";
24
+ import { startTestSidecar, sendCommand } from "./helpers/sidecar.mjs";
25
+ import { waitFor } from "./helpers/cleanup.mjs";
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+ const PLUGIN_DIR = path.resolve(__dirname, "..");
29
+ const HOOK_SCRIPT = path.join(PLUGIN_DIR, "scripts", "map-hook.mjs");
30
+ const SHORT_TMPDIR = "/tmp";
31
+
32
+ // Import pure builder functions from map-events
33
+ const {
34
+ buildSubagentSpawnCommand,
35
+ buildSubagentDoneCommand,
36
+ buildStateCommand,
37
+ buildTaskSyncPayload,
38
+ buildOpentasksBridgeCommands,
39
+ mapNativeTaskStatus,
40
+ } = await import("../src/map-events.mjs");
41
+
42
+ const { generateAgentMd } = await import("../src/agent-generator.mjs");
43
+
44
+ // Check if skill-tree is available
45
+ let skillTreeAvailable = false;
46
+ try {
47
+ const st = await import("skill-tree");
48
+ skillTreeAvailable = !!st.createSkillBank;
49
+ } catch {
50
+ // Not installed
51
+ }
52
+
53
+ /**
54
+ * Run a hook script with stdin data and return stdout + stderr.
55
+ */
56
+ function runHook(action, stdinData, cwd, env = {}) {
57
+ return new Promise((resolve) => {
58
+ const child = spawn("node", [HOOK_SCRIPT, action], {
59
+ cwd,
60
+ stdio: ["pipe", "pipe", "pipe"],
61
+ env: { ...process.env, ...env },
62
+ });
63
+
64
+ let stdout = "";
65
+ let stderr = "";
66
+ child.stdout.on("data", (d) => (stdout += d.toString()));
67
+ child.stderr.on("data", (d) => (stderr += d.toString()));
68
+
69
+ child.stdin.write(JSON.stringify(stdinData));
70
+ child.stdin.end();
71
+
72
+ child.on("close", (code) => {
73
+ resolve({ code, stdout, stderr });
74
+ });
75
+
76
+ setTimeout(() => {
77
+ child.kill();
78
+ resolve({ code: -1, stdout, stderr });
79
+ }, 15000);
80
+ });
81
+ }
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ // Group 1: MAP Event Builders (pure functions)
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+
87
+ describe(
88
+ "tier7: buildSubagentSpawnCommand",
89
+ { timeout: 15_000 },
90
+ () => {
91
+ it("builds spawn command with all fields", () => {
92
+ const cmd = buildSubagentSpawnCommand(
93
+ { agent_id: "sub-1", agent_type: "researcher", session_id: "sess-1" },
94
+ "gsd"
95
+ );
96
+
97
+ expect(cmd.action).toBe("spawn");
98
+ expect(cmd.agent.agentId).toBe("sub-1");
99
+ expect(cmd.agent.name).toBe("researcher");
100
+ expect(cmd.agent.role).toBe("subagent");
101
+ expect(cmd.agent.scopes).toEqual(["swarm:gsd"]);
102
+ expect(cmd.agent.metadata.agentType).toBe("researcher");
103
+ expect(cmd.agent.metadata.sessionId).toBe("sess-1");
104
+ expect(cmd.agent.metadata.isTeamRole).toBe(false);
105
+ });
106
+
107
+ it("uses fallback agentId when agent_id is missing", () => {
108
+ const cmd = buildSubagentSpawnCommand(
109
+ { agent_type: "coder" },
110
+ "test"
111
+ );
112
+
113
+ expect(cmd.agent.agentId).toMatch(/^test-subagent-/);
114
+ expect(cmd.agent.name).toBe("coder");
115
+ });
116
+
117
+ it("defaults empty agent_type to 'subagent'", () => {
118
+ const cmd = buildSubagentSpawnCommand({}, "t");
119
+
120
+ expect(cmd.agent.name).toBe("subagent");
121
+ expect(cmd.agent.metadata.agentType).toBe("");
122
+ });
123
+ }
124
+ );
125
+
126
+ describe(
127
+ "tier7: buildSubagentDoneCommand",
128
+ { timeout: 15_000 },
129
+ () => {
130
+ it("builds done command with agentId and reason", () => {
131
+ const cmd = buildSubagentDoneCommand(
132
+ { agent_id: "sub-1", last_assistant_message: "All done." },
133
+ "gsd"
134
+ );
135
+
136
+ expect(cmd.action).toBe("done");
137
+ expect(cmd.agentId).toBe("sub-1");
138
+ expect(cmd.reason).toBe("All done.");
139
+ });
140
+
141
+ it("truncates reason to 500 chars", () => {
142
+ const cmd = buildSubagentDoneCommand(
143
+ { agent_id: "x", last_assistant_message: "A".repeat(600) },
144
+ "gsd"
145
+ );
146
+
147
+ expect(cmd.reason.length).toBe(500);
148
+ });
149
+
150
+ it("defaults reason to 'completed' when no message", () => {
151
+ const cmd = buildSubagentDoneCommand({ agent_id: "x" }, "gsd");
152
+ expect(cmd.reason).toBe("completed");
153
+ });
154
+
155
+ it("defaults agentId to empty string when missing", () => {
156
+ const cmd = buildSubagentDoneCommand({}, "gsd");
157
+ expect(cmd.agentId).toBe("");
158
+ });
159
+ }
160
+ );
161
+
162
+ describe(
163
+ "tier7: buildStateCommand",
164
+ { timeout: 15_000 },
165
+ () => {
166
+ it("builds state command with agentId and metadata", () => {
167
+ const cmd = buildStateCommand("agent-1", "busy", {
168
+ lastStopReason: "tool_use",
169
+ });
170
+
171
+ expect(cmd).toEqual({
172
+ action: "state",
173
+ state: "busy",
174
+ agentId: "agent-1",
175
+ metadata: { lastStopReason: "tool_use" },
176
+ });
177
+ });
178
+
179
+ it("omits agentId when null (sidecar self-update)", () => {
180
+ const cmd = buildStateCommand(null, "idle");
181
+
182
+ expect(cmd.action).toBe("state");
183
+ expect(cmd.state).toBe("idle");
184
+ expect(cmd).not.toHaveProperty("agentId");
185
+ expect(cmd).not.toHaveProperty("metadata");
186
+ });
187
+
188
+ it("omits metadata when not provided", () => {
189
+ const cmd = buildStateCommand("a", "idle");
190
+
191
+ expect(cmd.action).toBe("state");
192
+ expect(cmd.agentId).toBe("a");
193
+ expect(cmd).not.toHaveProperty("metadata");
194
+ });
195
+ }
196
+ );
197
+
198
+ describe(
199
+ "tier7: buildTaskSyncPayload",
200
+ { timeout: 15_000 },
201
+ () => {
202
+ it("builds task.sync payload with tool_input fields", () => {
203
+ const payload = buildTaskSyncPayload(
204
+ { tool_input: { taskId: "t-1", status: "in_progress", subject: "Fix bug" } },
205
+ "gsd"
206
+ );
207
+
208
+ expect(payload).toEqual({
209
+ type: "task.sync",
210
+ uri: "claude://gsd/t-1",
211
+ status: "in_progress",
212
+ subject: "Fix bug",
213
+ source: "claude-code",
214
+ });
215
+ });
216
+
217
+ it("maps 'pending' status to 'open'", () => {
218
+ const payload = buildTaskSyncPayload(
219
+ { tool_input: { status: "pending" } },
220
+ "t"
221
+ );
222
+
223
+ expect(payload.status).toBe("open");
224
+ });
225
+
226
+ it("falls back to task_id and task_subject from hookData", () => {
227
+ const payload = buildTaskSyncPayload(
228
+ { task_id: "fallback-1", task_subject: "Subject" },
229
+ "t"
230
+ );
231
+
232
+ expect(payload.uri).toBe("claude://t/fallback-1");
233
+ expect(payload.subject).toBe("Subject");
234
+ });
235
+
236
+ it("defaults status to 'open' when missing", () => {
237
+ const payload = buildTaskSyncPayload({ tool_input: {} }, "t");
238
+ expect(payload.status).toBe("open");
239
+ });
240
+ }
241
+ );
242
+
243
+ describe(
244
+ "tier7: buildOpentasksBridgeCommands",
245
+ { timeout: 15_000 },
246
+ () => {
247
+ it("create_task → bridge-task-created + bridge-task-assigned", () => {
248
+ const cmds = buildOpentasksBridgeCommands({
249
+ tool_name: "opentasks__create_task",
250
+ tool_input: { title: "New", assignee: "exec" },
251
+ tool_output: JSON.stringify({
252
+ content: [{ text: JSON.stringify({ id: "ot-1", title: "New", status: "open", assignee: "exec" }) }],
253
+ }),
254
+ });
255
+
256
+ expect(cmds).toHaveLength(2);
257
+ expect(cmds[0].action).toBe("bridge-task-created");
258
+ expect(cmds[0].task.id).toBe("ot-1");
259
+ expect(cmds[0].task.title).toBe("New");
260
+ expect(cmds[0].task.assignee).toBe("exec");
261
+ expect(cmds[1].action).toBe("bridge-task-assigned");
262
+ expect(cmds[1].taskId).toBe("ot-1");
263
+ expect(cmds[1].assignee).toBe("exec");
264
+ });
265
+
266
+ it("create_task without assignee → only bridge-task-created", () => {
267
+ const cmds = buildOpentasksBridgeCommands({
268
+ tool_name: "opentasks__create_task",
269
+ tool_input: { title: "Solo" },
270
+ tool_output: JSON.stringify({
271
+ content: [{ text: JSON.stringify({ id: "ot-2", title: "Solo", status: "open" }) }],
272
+ }),
273
+ });
274
+
275
+ expect(cmds).toHaveLength(1);
276
+ expect(cmds[0].action).toBe("bridge-task-created");
277
+ });
278
+
279
+ it("create_task with no id and no title → empty array", () => {
280
+ const cmds = buildOpentasksBridgeCommands({
281
+ tool_name: "opentasks__create_task",
282
+ tool_input: {},
283
+ tool_output: null,
284
+ });
285
+
286
+ expect(cmds).toEqual([]);
287
+ });
288
+
289
+ it("update_task → bridge-task-status", () => {
290
+ const cmds = buildOpentasksBridgeCommands({
291
+ tool_name: "opentasks__update_task",
292
+ tool_input: { id: "ot-1", status: "in_progress" },
293
+ tool_output: JSON.stringify({
294
+ content: [{ text: JSON.stringify({ id: "ot-1", status: "in_progress" }) }],
295
+ }),
296
+ });
297
+
298
+ expect(cmds).toHaveLength(1);
299
+ expect(cmds[0].action).toBe("bridge-task-status");
300
+ expect(cmds[0].taskId).toBe("ot-1");
301
+ expect(cmds[0].current).toBe("in_progress");
302
+ });
303
+
304
+ it("update_task without id → empty array", () => {
305
+ const cmds = buildOpentasksBridgeCommands({
306
+ tool_name: "opentasks__update_task",
307
+ tool_input: {},
308
+ tool_output: null,
309
+ });
310
+
311
+ expect(cmds).toEqual([]);
312
+ });
313
+
314
+ it("link → emit task.linked", () => {
315
+ const cmds = buildOpentasksBridgeCommands({
316
+ tool_name: "opentasks__link",
317
+ tool_input: { fromId: "a", toId: "b", type: "blocks" },
318
+ });
319
+
320
+ expect(cmds).toHaveLength(1);
321
+ expect(cmds[0].action).toBe("emit");
322
+ expect(cmds[0].event.type).toBe("task.linked");
323
+ expect(cmds[0].event.from).toBe("a");
324
+ expect(cmds[0].event.to).toBe("b");
325
+ expect(cmds[0].event.linkType).toBe("blocks");
326
+ expect(cmds[0].event.remove).toBe(false);
327
+ expect(cmds[0].event.source).toBe("opentasks");
328
+ });
329
+
330
+ it("link without fromId or toId → empty array", () => {
331
+ const cmds = buildOpentasksBridgeCommands({
332
+ tool_name: "opentasks__link",
333
+ tool_input: { fromId: "a" },
334
+ });
335
+
336
+ expect(cmds).toEqual([]);
337
+ });
338
+
339
+ it("annotate → emit task.sync", () => {
340
+ const cmds = buildOpentasksBridgeCommands({
341
+ tool_name: "opentasks__annotate",
342
+ tool_input: { target: "native://t-1", feedback: { type: "review" } },
343
+ });
344
+
345
+ expect(cmds).toHaveLength(1);
346
+ expect(cmds[0].action).toBe("emit");
347
+ expect(cmds[0].event.type).toBe("task.sync");
348
+ expect(cmds[0].event.uri).toBe("native://t-1");
349
+ expect(cmds[0].event.annotation).toBe("review");
350
+ expect(cmds[0].event.source).toBe("opentasks");
351
+ });
352
+
353
+ it("annotate without target → empty array", () => {
354
+ const cmds = buildOpentasksBridgeCommands({
355
+ tool_name: "opentasks__annotate",
356
+ tool_input: {},
357
+ });
358
+
359
+ expect(cmds).toEqual([]);
360
+ });
361
+
362
+ it("read-only tools → empty array", () => {
363
+ for (const tool of ["opentasks__list_tasks", "opentasks__query", "opentasks__get_task", "opentasks__list_providers"]) {
364
+ const cmds = buildOpentasksBridgeCommands({
365
+ tool_name: tool,
366
+ tool_input: {},
367
+ });
368
+ expect(cmds).toEqual([]);
369
+ }
370
+ });
371
+ }
372
+ );
373
+
374
+ // ─────────────────────────────────────────────────────────────────────────────
375
+ // Group 2: mapNativeTaskStatus
376
+ // ─────────────────────────────────────────────────────────────────────────────
377
+
378
+ describe(
379
+ "tier7: mapNativeTaskStatus",
380
+ { timeout: 15_000 },
381
+ () => {
382
+ it("maps 'pending' to 'open'", () => {
383
+ expect(mapNativeTaskStatus("pending")).toBe("open");
384
+ });
385
+
386
+ it("maps 'in_progress' to 'in_progress'", () => {
387
+ expect(mapNativeTaskStatus("in_progress")).toBe("in_progress");
388
+ });
389
+
390
+ it("maps 'completed' to 'completed'", () => {
391
+ expect(mapNativeTaskStatus("completed")).toBe("completed");
392
+ });
393
+
394
+ it("passes unknown status through unchanged", () => {
395
+ expect(mapNativeTaskStatus("blocked")).toBe("blocked");
396
+ });
397
+
398
+ it("returns 'open' for undefined", () => {
399
+ expect(mapNativeTaskStatus(undefined)).toBe("open");
400
+ });
401
+
402
+ it("returns 'open' for empty string", () => {
403
+ expect(mapNativeTaskStatus("")).toBe("open");
404
+ });
405
+ }
406
+ );
407
+
408
+ // ─────────────────────────────────────────────────────────────────────────────
409
+ // Group 3: Sidecar IPC Round-Trip (bridge commands → mock MAP server)
410
+ // ─────────────────────────────────────────────────────────────────────────────
411
+
412
+ describe(
413
+ "tier7: sidecar bridge commands",
414
+ { timeout: 60_000 },
415
+ () => {
416
+ let mockServer;
417
+ let workspace;
418
+ let sidecar;
419
+
420
+ beforeAll(async () => {
421
+ mockServer = new MockMapServer();
422
+ await mockServer.start();
423
+ });
424
+
425
+ afterAll(async () => {
426
+ if (sidecar) {
427
+ sidecar.cleanup();
428
+ sidecar = null;
429
+ }
430
+ if (workspace) {
431
+ workspace.cleanup();
432
+ workspace = null;
433
+ }
434
+ await mockServer.stop();
435
+ });
436
+
437
+ afterEach(() => {
438
+ mockServer.clearMessages();
439
+ });
440
+
441
+ // Start sidecar once for all bridge tests
442
+ it("starts sidecar and connects to mock MAP", async () => {
443
+ workspace = createWorkspace({
444
+ tmpdir: SHORT_TMPDIR,
445
+ prefix: "t7h-bridge-",
446
+ config: {
447
+ template: "gsd",
448
+ map: { enabled: true, server: `ws://localhost:${mockServer.port}` },
449
+ },
450
+ });
451
+
452
+ sidecar = await startTestSidecar({
453
+ workspaceDir: workspace.dir,
454
+ mockServerPort: mockServer.port,
455
+ });
456
+
457
+ expect(sidecar.pid).toBeGreaterThan(0);
458
+
459
+ // Verify MAP connection
460
+ const connectMsgs = mockServer.getByMethod("map/connect");
461
+ expect(connectMsgs.length).toBeGreaterThan(0);
462
+ });
463
+
464
+ it("bridge-task-created reaches MAP as task.created message", async () => {
465
+ const resp = await sendCommand(sidecar.socketPath, {
466
+ action: "bridge-task-created",
467
+ task: { id: "t-1", title: "Test Task", status: "open", assignee: "exec" },
468
+ agentId: "exec",
469
+ });
470
+ expect(resp?.ok).toBe(true);
471
+
472
+ // Wait for message to arrive on mock server
473
+ const found = await waitFor(() => {
474
+ return mockServer.sentMessages.some(
475
+ (m) => m.payload?.type === "task.created"
476
+ );
477
+ }, 3000);
478
+ expect(found).toBe(true);
479
+
480
+ const msg = mockServer.sentMessages.find(
481
+ (m) => m.payload?.type === "task.created"
482
+ );
483
+ expect(msg.payload.task.id).toBe("t-1");
484
+ expect(msg.payload.task.title).toBe("Test Task");
485
+ expect(msg.payload._origin).toBe("exec");
486
+ });
487
+
488
+ it("bridge-task-status reaches MAP as task.status message", async () => {
489
+ const resp = await sendCommand(sidecar.socketPath, {
490
+ action: "bridge-task-status",
491
+ taskId: "t-1",
492
+ previous: "open",
493
+ current: "in_progress",
494
+ agentId: "exec",
495
+ });
496
+ expect(resp?.ok).toBe(true);
497
+
498
+ const found = await waitFor(() => {
499
+ return mockServer.sentMessages.some(
500
+ (m) => m.payload?.type === "task.status"
501
+ );
502
+ }, 3000);
503
+ expect(found).toBe(true);
504
+
505
+ const msg = mockServer.sentMessages.find(
506
+ (m) => m.payload?.type === "task.status"
507
+ );
508
+ expect(msg.payload.taskId).toBe("t-1");
509
+ expect(msg.payload.previous).toBe("open");
510
+ expect(msg.payload.current).toBe("in_progress");
511
+ });
512
+
513
+ it("bridge-task-status with 'completed' also emits task.completed", async () => {
514
+ const resp = await sendCommand(sidecar.socketPath, {
515
+ action: "bridge-task-status",
516
+ taskId: "t-2",
517
+ previous: "in_progress",
518
+ current: "completed",
519
+ agentId: "exec",
520
+ });
521
+ expect(resp?.ok).toBe(true);
522
+
523
+ const found = await waitFor(() => {
524
+ return mockServer.sentMessages.some(
525
+ (m) => m.payload?.type === "task.completed"
526
+ );
527
+ }, 3000);
528
+ expect(found).toBe(true);
529
+
530
+ const completedMsg = mockServer.sentMessages.find(
531
+ (m) => m.payload?.type === "task.completed"
532
+ );
533
+ expect(completedMsg.payload.taskId).toBe("t-2");
534
+ });
535
+
536
+ it("bridge-task-assigned reaches MAP as task.assigned message", async () => {
537
+ const resp = await sendCommand(sidecar.socketPath, {
538
+ action: "bridge-task-assigned",
539
+ taskId: "t-1",
540
+ assignee: "worker-1",
541
+ agentId: "worker-1",
542
+ });
543
+ expect(resp?.ok).toBe(true);
544
+
545
+ const found = await waitFor(() => {
546
+ return mockServer.sentMessages.some(
547
+ (m) => m.payload?.type === "task.assigned"
548
+ );
549
+ }, 3000);
550
+ expect(found).toBe(true);
551
+
552
+ const msg = mockServer.sentMessages.find(
553
+ (m) => m.payload?.type === "task.assigned"
554
+ );
555
+ expect(msg.payload.taskId).toBe("t-1");
556
+ expect(msg.payload.agentId).toBe("worker-1");
557
+ });
558
+
559
+ it("multiple bridge commands in sequence all arrive", async () => {
560
+ const before = mockServer.sentMessages.length;
561
+
562
+ await sendCommand(sidecar.socketPath, {
563
+ action: "bridge-task-created",
564
+ task: { id: "seq-1", title: "A", status: "open" },
565
+ agentId: "opentasks",
566
+ });
567
+ await sendCommand(sidecar.socketPath, {
568
+ action: "bridge-task-assigned",
569
+ taskId: "seq-1",
570
+ assignee: "w",
571
+ agentId: "w",
572
+ });
573
+ await sendCommand(sidecar.socketPath, {
574
+ action: "bridge-task-status",
575
+ taskId: "seq-1",
576
+ current: "in_progress",
577
+ agentId: "w",
578
+ });
579
+
580
+ const found = await waitFor(() => {
581
+ return mockServer.sentMessages.length >= before + 3;
582
+ }, 5000);
583
+ expect(found).toBe(true);
584
+ });
585
+ }
586
+ );
587
+
588
+ // ─────────────────────────────────────────────────────────────────────────────
589
+ // Group 4: Hook Script Integration
590
+ // ─────────────────────────────────────────────────────────────────────────────
591
+
592
+ describe(
593
+ "tier7: hook script integration",
594
+ { timeout: 60_000 },
595
+ () => {
596
+ let mockServer;
597
+ let workspace;
598
+ let sidecar;
599
+
600
+ beforeAll(async () => {
601
+ mockServer = new MockMapServer();
602
+ await mockServer.start();
603
+
604
+ workspace = createWorkspace({
605
+ tmpdir: SHORT_TMPDIR,
606
+ prefix: "t7h-hooks-",
607
+ config: {
608
+ template: "gsd",
609
+ map: { enabled: true, server: `ws://localhost:${mockServer.port}` },
610
+ },
611
+ });
612
+
613
+ sidecar = await startTestSidecar({
614
+ workspaceDir: workspace.dir,
615
+ mockServerPort: mockServer.port,
616
+ });
617
+ });
618
+
619
+ afterAll(async () => {
620
+ if (sidecar) {
621
+ sidecar.cleanup();
622
+ sidecar = null;
623
+ }
624
+ if (workspace) {
625
+ workspace.cleanup();
626
+ workspace = null;
627
+ }
628
+ await mockServer.stop();
629
+ });
630
+
631
+ afterEach(() => {
632
+ mockServer.clearMessages();
633
+ });
634
+
635
+ it("subagent-start hook sends spawn to MAP", async () => {
636
+ const result = await runHook(
637
+ "subagent-start",
638
+ { agent_id: "sub-hook-1", agent_type: "researcher", session_id: "" },
639
+ workspace.dir
640
+ );
641
+
642
+ expect(result.code).toBe(0);
643
+
644
+ const found = await waitFor(() => {
645
+ return mockServer.spawnedAgents.some(
646
+ (a) => a.agentId === "sub-hook-1"
647
+ );
648
+ }, 5000);
649
+ expect(found).toBe(true);
650
+
651
+ const agent = mockServer.spawnedAgents.find(
652
+ (a) => a.agentId === "sub-hook-1"
653
+ );
654
+ expect(agent.role).toBe("subagent");
655
+ });
656
+
657
+ it("subagent-stop hook sends done/unregister to MAP", async () => {
658
+ // First spawn a subagent so it's registered
659
+ await sendCommand(sidecar.socketPath, {
660
+ action: "spawn",
661
+ agent: {
662
+ agentId: "sub-hook-2",
663
+ name: "worker",
664
+ role: "subagent",
665
+ scopes: ["swarm:gsd"],
666
+ metadata: {},
667
+ },
668
+ });
669
+
670
+ await waitFor(() => {
671
+ return mockServer.spawnedAgents.some(
672
+ (a) => a.agentId === "sub-hook-2"
673
+ );
674
+ }, 3000);
675
+
676
+ mockServer.clearMessages();
677
+
678
+ const result = await runHook(
679
+ "subagent-stop",
680
+ { agent_id: "sub-hook-2", last_assistant_message: "Done with work.", session_id: "" },
681
+ workspace.dir
682
+ );
683
+
684
+ expect(result.code).toBe(0);
685
+
686
+ const found = await waitFor(() => {
687
+ return mockServer.callExtensions.some(
688
+ (c) => c.method === "map/agents/unregister" && c.params?.agentId === "sub-hook-2"
689
+ );
690
+ }, 5000);
691
+ expect(found).toBe(true);
692
+ });
693
+
694
+ it("turn-completed hook sends state idle to MAP", async () => {
695
+ const result = await runHook(
696
+ "turn-completed",
697
+ { stop_reason: "end_turn", session_id: "" },
698
+ workspace.dir
699
+ );
700
+
701
+ expect(result.code).toBe(0);
702
+
703
+ const found = await waitFor(() => {
704
+ return mockServer.stateUpdates.length > 0;
705
+ }, 5000);
706
+ expect(found).toBe(true);
707
+ });
708
+
709
+ it("native-task-created hook sends bridge events to MAP", async () => {
710
+ const result = await runHook(
711
+ "native-task-created",
712
+ {
713
+ tool_input: { subject: "Fix bug", status: "pending", owner: "worker-1" },
714
+ tool_output: { id: "nt-1" },
715
+ session_id: "",
716
+ },
717
+ workspace.dir
718
+ );
719
+
720
+ expect(result.code).toBe(0);
721
+
722
+ const found = await waitFor(() => {
723
+ return mockServer.sentMessages.some(
724
+ (m) => m.payload?.type === "task.created"
725
+ );
726
+ }, 5000);
727
+ expect(found).toBe(true);
728
+
729
+ const msg = mockServer.sentMessages.find(
730
+ (m) => m.payload?.type === "task.created"
731
+ );
732
+ expect(msg.payload.task.id).toBe("nt-1");
733
+ expect(msg.payload.task.title).toBe("Fix bug");
734
+
735
+ // Should also have task.assigned since owner was provided
736
+ const assignedFound = await waitFor(() => {
737
+ return mockServer.sentMessages.some(
738
+ (m) => m.payload?.type === "task.assigned"
739
+ );
740
+ }, 3000);
741
+ expect(assignedFound).toBe(true);
742
+ });
743
+
744
+ it("native-task-updated hook sends bridge-task-status to MAP", async () => {
745
+ const result = await runHook(
746
+ "native-task-updated",
747
+ {
748
+ tool_input: { taskId: "nt-1", status: "completed" },
749
+ session_id: "",
750
+ },
751
+ workspace.dir
752
+ );
753
+
754
+ expect(result.code).toBe(0);
755
+
756
+ const found = await waitFor(() => {
757
+ return mockServer.sentMessages.some(
758
+ (m) => m.payload?.type === "task.status"
759
+ );
760
+ }, 5000);
761
+ expect(found).toBe(true);
762
+
763
+ const msg = mockServer.sentMessages.find(
764
+ (m) => m.payload?.type === "task.status"
765
+ );
766
+ expect(msg.payload.taskId).toBe("nt-1");
767
+ expect(msg.payload.current).toBe("completed");
768
+ });
769
+
770
+ it("opentasks-mcp-used hook sends bridge commands for create_task", async () => {
771
+ const result = await runHook(
772
+ "opentasks-mcp-used",
773
+ {
774
+ tool_name: "opentasks__create_task",
775
+ tool_input: { title: "OT task", assignee: "dev" },
776
+ tool_output: JSON.stringify({
777
+ content: [{ text: JSON.stringify({ id: "ot-hook-1", title: "OT task", status: "open", assignee: "dev" }) }],
778
+ }),
779
+ session_id: "",
780
+ },
781
+ workspace.dir
782
+ );
783
+
784
+ expect(result.code).toBe(0);
785
+
786
+ const found = await waitFor(() => {
787
+ return mockServer.sentMessages.some(
788
+ (m) => m.payload?.type === "task.created"
789
+ );
790
+ }, 5000);
791
+ expect(found).toBe(true);
792
+
793
+ const msg = mockServer.sentMessages.find(
794
+ (m) => m.payload?.type === "task.created"
795
+ );
796
+ expect(msg.payload.task.id).toBe("ot-hook-1");
797
+ });
798
+
799
+ it("opentasks-mcp-used hook is no-op when MAP disabled", async () => {
800
+ // Create a separate workspace with MAP disabled
801
+ const noMapWorkspace = createWorkspace({
802
+ tmpdir: SHORT_TMPDIR,
803
+ prefix: "t7h-nomap-",
804
+ config: { template: "gsd", map: { enabled: false } },
805
+ });
806
+
807
+ try {
808
+ const beforeCount = mockServer.sentMessages.length;
809
+
810
+ const result = await runHook(
811
+ "opentasks-mcp-used",
812
+ {
813
+ tool_name: "opentasks__create_task",
814
+ tool_input: { title: "Should not send" },
815
+ tool_output: JSON.stringify({
816
+ content: [{ text: JSON.stringify({ id: "skip-1", title: "Should not send" }) }],
817
+ }),
818
+ session_id: "",
819
+ },
820
+ noMapWorkspace.dir
821
+ );
822
+
823
+ expect(result.code).toBe(0);
824
+
825
+ // Wait a moment, then verify no new messages
826
+ await new Promise((r) => setTimeout(r, 1000));
827
+ expect(mockServer.sentMessages.length).toBe(beforeCount);
828
+ } finally {
829
+ noMapWorkspace.cleanup();
830
+ }
831
+ });
832
+
833
+ it("teammate-idle hook sends state command", async () => {
834
+ // Write roles.json so matchRole can find it
835
+ const mapDir = path.join(
836
+ fs.realpathSync(workspace.dir),
837
+ ".swarm", "claude-swarm", "tmp", "map"
838
+ );
839
+ fs.mkdirSync(mapDir, { recursive: true });
840
+ fs.writeFileSync(
841
+ path.join(mapDir, "roles.json"),
842
+ JSON.stringify([
843
+ { name: "executor", role: "executor" },
844
+ { name: "verifier", role: "verifier" },
845
+ ])
846
+ );
847
+
848
+ const result = await runHook(
849
+ "teammate-idle",
850
+ { teammate_name: "executor", session_id: "" },
851
+ workspace.dir
852
+ );
853
+
854
+ expect(result.code).toBe(0);
855
+
856
+ // State update should be sent (may or may not have agentId depending on registered agents)
857
+ const found = await waitFor(() => {
858
+ return mockServer.stateUpdates.length > 0;
859
+ }, 5000);
860
+ expect(found).toBe(true);
861
+ });
862
+ }
863
+ );
864
+
865
+ // ─────────────────────────────────────────────────────────────────────────────
866
+ // Group 5: Full Pipeline (skill-tree → agent generation)
867
+ // ─────────────────────────────────────────────────────────────────────────────
868
+
869
+ describe(
870
+ "tier7: skill-tree → agent generation pipeline",
871
+ { timeout: 15_000 },
872
+ () => {
873
+ let workspace;
874
+
875
+ afterEach(() => {
876
+ if (workspace) {
877
+ workspace.cleanup();
878
+ workspace = null;
879
+ }
880
+ });
881
+
882
+ it("generateAgentMd embeds skill loadout from skill-loadouts.json", () => {
883
+ workspace = createWorkspace({
884
+ tmpdir: SHORT_TMPDIR,
885
+ prefix: "t7h-pipeline-",
886
+ config: { template: "gsd", skilltree: { enabled: true } },
887
+ });
888
+
889
+ // Write a cached skill-loadouts.json
890
+ const cacheDir = path.join(workspace.dir, ".swarm", "claude-swarm", "tmp", "teams", "gsd");
891
+ fs.mkdirSync(cacheDir, { recursive: true });
892
+ const loadouts = {
893
+ executor: {
894
+ content: "## Skill: Clean Code\n\nWrite clean, readable code.\n\n## Skill: TDD\n\nAlways write tests first.",
895
+ profile: "implementation",
896
+ },
897
+ };
898
+ fs.writeFileSync(
899
+ path.join(cacheDir, "skill-loadouts.json"),
900
+ JSON.stringify(loadouts)
901
+ );
902
+
903
+ // Generate AGENT.md for executor with the loadout
904
+ const md = generateAgentMd({
905
+ roleName: "executor",
906
+ teamName: "gsd",
907
+ position: "spawned",
908
+ description: "Executor agent",
909
+ tools: ["Read", "Write", "Bash"],
910
+ skillContent: "# Role: executor\n\nExecute tasks.",
911
+ manifest: {},
912
+ skilltreeEnabled: true,
913
+ skilltreeStatus: "ready",
914
+ skillLoadout: loadouts.executor.content,
915
+ skillProfile: loadouts.executor.profile,
916
+ });
917
+
918
+ expect(md).toContain("## Skills");
919
+ expect(md).toContain("Clean Code");
920
+ expect(md).toContain("TDD");
921
+ expect(md).toContain("Write clean, readable code");
922
+ });
923
+
924
+ it("generateAgentMd works without skill loadout (no crash)", () => {
925
+ const md = generateAgentMd({
926
+ roleName: "executor",
927
+ teamName: "gsd",
928
+ position: "spawned",
929
+ description: "Executor agent",
930
+ tools: ["Read", "Write", "Bash"],
931
+ skillContent: "# Role: executor\n\nExecute tasks.",
932
+ manifest: {},
933
+ skilltreeEnabled: false,
934
+ skilltreeStatus: "disabled",
935
+ });
936
+
937
+ expect(md).toBeTruthy();
938
+ expect(md).toContain("executor");
939
+ // Should not have standalone "## Skills" section
940
+ expect(md).not.toMatch(/^## Skills$/m);
941
+ });
942
+
943
+ it("skill-loadouts.json round-trips through file I/O correctly", () => {
944
+ workspace = createWorkspace({
945
+ tmpdir: SHORT_TMPDIR,
946
+ prefix: "t7h-cache-",
947
+ config: { template: "gsd", skilltree: { enabled: true } },
948
+ });
949
+
950
+ const cacheDir = path.join(workspace.dir, ".swarm", "claude-swarm", "tmp", "teams", "gsd");
951
+ fs.mkdirSync(cacheDir, { recursive: true });
952
+
953
+ const loadouts = {
954
+ executor: { content: "## Skill: A\n\nContent A.", profile: "implementation" },
955
+ verifier: { content: "## Skill: B\n\nContent B.", profile: "testing" },
956
+ debugger: { content: "## Skill: C\n\nContent C.", profile: "debugging" },
957
+ };
958
+
959
+ // Write
960
+ const filePath = path.join(cacheDir, "skill-loadouts.json");
961
+ fs.writeFileSync(filePath, JSON.stringify(loadouts));
962
+
963
+ // Read back
964
+ const read = JSON.parse(fs.readFileSync(filePath, "utf-8"));
965
+ expect(Object.keys(read)).toEqual(["executor", "verifier", "debugger"]);
966
+ expect(read.executor.content).toContain("Skill: A");
967
+ expect(read.executor.profile).toBe("implementation");
968
+ expect(read.verifier.profile).toBe("testing");
969
+ expect(read.debugger.profile).toBe("debugging");
970
+
971
+ // Generate AGENT.md for each role from the cache
972
+ for (const [role, data] of Object.entries(read)) {
973
+ const md = generateAgentMd({
974
+ roleName: role,
975
+ teamName: "gsd",
976
+ position: "spawned",
977
+ description: `${role} agent`,
978
+ tools: ["Read", "Bash"],
979
+ skillContent: `# Role: ${role}\n\nDo ${role} things.`,
980
+ manifest: {},
981
+ skilltreeEnabled: true,
982
+ skilltreeStatus: "ready",
983
+ skillLoadout: data.content,
984
+ skillProfile: data.profile,
985
+ });
986
+
987
+ expect(md).toContain("## Skills");
988
+ expect(md).toContain(data.profile);
989
+ }
990
+ });
991
+ }
992
+ );