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,513 @@
1
+ /**
2
+ * Tier 7: OpenTasks Integration Tests
3
+ *
4
+ * Tests the opentasks IPC client and MAP bridge without LLM calls:
5
+ * 1. rpcRequest round-trip via a test daemon
6
+ * 2. createTask / updateTask JSON-RPC round-trip
7
+ * 3. pushSyncEvent for various event types
8
+ * 4. findSocketPath discovery priority
9
+ * 5. isDaemonAlive with live and dead sockets
10
+ * 6. MAP bridge events for task lifecycle (sidecar + mock MAP server)
11
+ *
12
+ * Uses a minimal test daemon (e2e/helpers/opentasks-daemon.mjs) for IPC tests.
13
+ * No LLM calls.
14
+ *
15
+ * Run:
16
+ * npx vitest run --config e2e/vitest.config.e2e.mjs e2e/tier7-opentasks.test.mjs
17
+ */
18
+
19
+ import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
20
+ import fs from "fs";
21
+ import path from "path";
22
+ import { fileURLToPath } from "url";
23
+ import { createWorkspace } from "./helpers/workspace.mjs";
24
+ import { startTestDaemon } from "./helpers/opentasks-daemon.mjs";
25
+ import { MockMapServer } from "./helpers/map-mock-server.mjs";
26
+ import { startTestSidecar, sendCommand } from "./helpers/sidecar.mjs";
27
+ import { waitFor } from "./helpers/cleanup.mjs";
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ const SHORT_TMPDIR = "/tmp";
31
+
32
+ // Import opentasks client functions
33
+ const {
34
+ findSocketPath,
35
+ rpcRequest,
36
+ isDaemonAlive,
37
+ createTask,
38
+ updateTask,
39
+ pushSyncEvent,
40
+ } = await import("../src/opentasks-client.mjs");
41
+
42
+ const { buildCapabilitiesContext } = await import("../src/context-output.mjs");
43
+
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Group 1: findSocketPath — socket discovery priority
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ describe(
49
+ "tier7: opentasks findSocketPath",
50
+ { timeout: 15_000 },
51
+ () => {
52
+ let workspace;
53
+ let origCwd;
54
+
55
+ afterEach(() => {
56
+ if (origCwd) process.chdir(origCwd);
57
+ if (workspace) {
58
+ workspace.cleanup();
59
+ workspace = null;
60
+ }
61
+ });
62
+
63
+ it("prefers .swarm/opentasks/ layout", () => {
64
+ workspace = createWorkspace({ tmpdir: SHORT_TMPDIR, prefix: "t7-ot-" });
65
+ origCwd = process.cwd();
66
+ process.chdir(workspace.dir);
67
+
68
+ const swarmSock = path.join(workspace.dir, ".swarm", "opentasks", "daemon.sock");
69
+ fs.mkdirSync(path.dirname(swarmSock), { recursive: true });
70
+ fs.writeFileSync(swarmSock, "");
71
+
72
+ expect(findSocketPath()).toBe(path.join(".swarm", "opentasks", "daemon.sock"));
73
+ });
74
+
75
+ it("falls back to .opentasks/ layout", () => {
76
+ workspace = createWorkspace({ tmpdir: SHORT_TMPDIR, prefix: "t7-ot-" });
77
+ origCwd = process.cwd();
78
+ process.chdir(workspace.dir);
79
+
80
+ const otSock = path.join(workspace.dir, ".opentasks", "daemon.sock");
81
+ fs.mkdirSync(path.dirname(otSock), { recursive: true });
82
+ fs.writeFileSync(otSock, "");
83
+
84
+ expect(findSocketPath()).toBe(path.join(".opentasks", "daemon.sock"));
85
+ });
86
+
87
+ it("falls back to .git/opentasks/ layout", () => {
88
+ workspace = createWorkspace({ tmpdir: SHORT_TMPDIR, prefix: "t7-ot-" });
89
+ origCwd = process.cwd();
90
+ process.chdir(workspace.dir);
91
+
92
+ const gitSock = path.join(workspace.dir, ".git", "opentasks", "daemon.sock");
93
+ fs.mkdirSync(path.dirname(gitSock), { recursive: true });
94
+ fs.writeFileSync(gitSock, "");
95
+
96
+ expect(findSocketPath()).toBe(path.join(".git", "opentasks", "daemon.sock"));
97
+ });
98
+
99
+ it("returns default swarmkit path when no socket exists", () => {
100
+ workspace = createWorkspace({ tmpdir: SHORT_TMPDIR, prefix: "t7-ot-" });
101
+ origCwd = process.cwd();
102
+ process.chdir(workspace.dir);
103
+
104
+ expect(findSocketPath()).toBe(path.join(".swarm", "opentasks", "daemon.sock"));
105
+ });
106
+ }
107
+ );
108
+
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+ // Group 2: Daemon IPC — rpcRequest, isDaemonAlive with test daemon
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+
113
+ describe(
114
+ "tier7: opentasks daemon IPC",
115
+ { timeout: 30_000 },
116
+ () => {
117
+ let daemon;
118
+ let workspace;
119
+
120
+ beforeAll(async () => {
121
+ workspace = createWorkspace({ tmpdir: SHORT_TMPDIR, prefix: "t7-ot-ipc-" });
122
+ const sockPath = path.join(workspace.dir, "daemon.sock");
123
+ daemon = await startTestDaemon(sockPath);
124
+ });
125
+
126
+ afterAll(async () => {
127
+ if (daemon) await daemon.stop();
128
+ if (workspace) workspace.cleanup();
129
+ });
130
+
131
+ it("rpcRequest ping returns result", async () => {
132
+ const result = await rpcRequest("ping", {}, daemon.socketPath);
133
+ expect(result).not.toBeNull();
134
+ expect(result.pong).toBe(true);
135
+ });
136
+
137
+ it("isDaemonAlive returns true for running daemon", async () => {
138
+ const alive = await isDaemonAlive(daemon.socketPath);
139
+ expect(alive).toBe(true);
140
+ });
141
+
142
+ it("isDaemonAlive returns false for non-existent socket", async () => {
143
+ const alive = await isDaemonAlive("/tmp/nonexistent-" + Date.now() + ".sock");
144
+ expect(alive).toBe(false);
145
+ });
146
+
147
+ it("rpcRequest with unknown method returns null", async () => {
148
+ const result = await rpcRequest("nonexistent.method", {}, daemon.socketPath);
149
+ expect(result).toBeNull();
150
+ });
151
+
152
+ it("rpcRequest with dead socket returns null (never throws)", async () => {
153
+ const result = await rpcRequest("ping", {}, "/tmp/no-daemon-" + Date.now() + ".sock");
154
+ expect(result).toBeNull();
155
+ });
156
+ }
157
+ );
158
+
159
+ // ─────────────────────────────────────────────────────────────────────────────
160
+ // Group 3: Task CRUD — createTask, updateTask round-trip
161
+ // ─────────────────────────────────────────────────────────────────────────────
162
+
163
+ describe(
164
+ "tier7: opentasks task CRUD",
165
+ { timeout: 30_000 },
166
+ () => {
167
+ let daemon;
168
+ let workspace;
169
+
170
+ beforeAll(async () => {
171
+ workspace = createWorkspace({ tmpdir: SHORT_TMPDIR, prefix: "t7-ot-crud-" });
172
+ const sockPath = path.join(workspace.dir, "daemon.sock");
173
+ daemon = await startTestDaemon(sockPath);
174
+ });
175
+
176
+ afterAll(async () => {
177
+ if (daemon) await daemon.stop();
178
+ if (workspace) workspace.cleanup();
179
+ });
180
+
181
+ it("createTask returns a node with an ID", async () => {
182
+ const result = await createTask(daemon.socketPath, {
183
+ title: "Test task from tier7",
184
+ status: "open",
185
+ assignee: "test-agent",
186
+ metadata: { source: "e2e-test" },
187
+ });
188
+
189
+ expect(result).not.toBeNull();
190
+ expect(result.id).toBeTruthy();
191
+ expect(result.title).toBe("Test task from tier7");
192
+ expect(result.status).toBe("open");
193
+ expect(result.assignee).toBe("test-agent");
194
+
195
+ // Verify it's in the daemon's storage
196
+ expect(daemon.nodes.has(result.id)).toBe(true);
197
+ });
198
+
199
+ it("updateTask changes task status", async () => {
200
+ const created = await createTask(daemon.socketPath, {
201
+ title: "Task to update",
202
+ status: "open",
203
+ });
204
+ expect(created).not.toBeNull();
205
+
206
+ const updated = await updateTask(daemon.socketPath, created.id, {
207
+ status: "in_progress",
208
+ assignee: "gsd-executor",
209
+ });
210
+
211
+ expect(updated).not.toBeNull();
212
+ expect(updated.status).toBe("in_progress");
213
+ expect(updated.assignee).toBe("gsd-executor");
214
+
215
+ // Verify in daemon storage
216
+ const stored = daemon.nodes.get(created.id);
217
+ expect(stored.status).toBe("in_progress");
218
+ });
219
+
220
+ it("updateTask with non-existent ID returns null", async () => {
221
+ const result = await updateTask(daemon.socketPath, "nonexistent-id", {
222
+ status: "done",
223
+ });
224
+ expect(result).toBeNull();
225
+ });
226
+
227
+ it("createTask with missing socket returns null (never throws)", async () => {
228
+ const result = await createTask("/tmp/no-daemon-" + Date.now() + ".sock", {
229
+ title: "Should fail gracefully",
230
+ status: "open",
231
+ });
232
+ expect(result).toBeNull();
233
+ });
234
+ }
235
+ );
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // Group 4: pushSyncEvent — forwarding MAP task events to graph
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+
241
+ describe(
242
+ "tier7: opentasks pushSyncEvent",
243
+ { timeout: 30_000 },
244
+ () => {
245
+ let daemon;
246
+ let workspace;
247
+
248
+ beforeAll(async () => {
249
+ workspace = createWorkspace({ tmpdir: SHORT_TMPDIR, prefix: "t7-ot-sync-" });
250
+ const sockPath = path.join(workspace.dir, "daemon.sock");
251
+ daemon = await startTestDaemon(sockPath);
252
+ });
253
+
254
+ afterAll(async () => {
255
+ if (daemon) await daemon.stop();
256
+ if (workspace) workspace.cleanup();
257
+ });
258
+
259
+ it("task.sync creates a new node in the graph", async () => {
260
+ const ok = await pushSyncEvent(daemon.socketPath, {
261
+ type: "task.sync",
262
+ uri: "map://remote-system/task-1",
263
+ subject: "Remote task synced from MAP",
264
+ status: "open",
265
+ source: "map-bridge",
266
+ });
267
+ expect(ok).toBe(true);
268
+
269
+ // Should have created a node
270
+ const nodes = Array.from(daemon.nodes.values());
271
+ const synced = nodes.find((n) => n.uri === "map://remote-system/task-1");
272
+ expect(synced).toBeTruthy();
273
+ });
274
+
275
+ it("task.sync with ID updates existing node", async () => {
276
+ const created = await createTask(daemon.socketPath, {
277
+ title: "Task to sync-update",
278
+ status: "open",
279
+ });
280
+
281
+ const ok = await pushSyncEvent(daemon.socketPath, {
282
+ type: "task.sync",
283
+ id: created.id,
284
+ subject: "Updated via sync",
285
+ status: "in_progress",
286
+ source: "map-bridge",
287
+ });
288
+ expect(ok).toBe(true);
289
+
290
+ const stored = daemon.nodes.get(created.id);
291
+ expect(stored.status).toBe("in_progress");
292
+ });
293
+
294
+ it("task.claimed updates assignee", async () => {
295
+ const created = await createTask(daemon.socketPath, {
296
+ title: "Task to claim",
297
+ status: "open",
298
+ });
299
+
300
+ const ok = await pushSyncEvent(daemon.socketPath, {
301
+ type: "task.claimed",
302
+ id: created.id,
303
+ agent: "gsd-executor",
304
+ source: "map-bridge",
305
+ });
306
+ expect(ok).toBe(true);
307
+
308
+ const stored = daemon.nodes.get(created.id);
309
+ expect(stored.status).toBe("in_progress");
310
+ expect(stored.assignee).toBe("gsd-executor");
311
+ });
312
+
313
+ it("task.unblocked resets status to open", async () => {
314
+ const created = await createTask(daemon.socketPath, {
315
+ title: "Blocked task",
316
+ status: "blocked",
317
+ });
318
+
319
+ const ok = await pushSyncEvent(daemon.socketPath, {
320
+ type: "task.unblocked",
321
+ id: created.id,
322
+ unblockedBy: "gsd-debugger",
323
+ source: "map-bridge",
324
+ });
325
+ expect(ok).toBe(true);
326
+
327
+ const stored = daemon.nodes.get(created.id);
328
+ expect(stored.status).toBe("open");
329
+ });
330
+
331
+ it("task.linked creates an edge", async () => {
332
+ const from = await createTask(daemon.socketPath, { title: "From", status: "open" });
333
+ const to = await createTask(daemon.socketPath, { title: "To", status: "open" });
334
+
335
+ const ok = await pushSyncEvent(daemon.socketPath, {
336
+ type: "task.linked",
337
+ from: from.id,
338
+ to: to.id,
339
+ linkType: "blocks",
340
+ source: "map-bridge",
341
+ });
342
+ expect(ok).toBe(true);
343
+
344
+ expect(daemon.edges.length).toBeGreaterThan(0);
345
+ const edge = daemon.edges.find((e) => e.fromId === from.id && e.toId === to.id);
346
+ expect(edge).toBeTruthy();
347
+ expect(edge.type).toBe("blocks");
348
+ });
349
+
350
+ it("unknown event type returns false", async () => {
351
+ const ok = await pushSyncEvent(daemon.socketPath, {
352
+ type: "task.unknown_event",
353
+ id: "fake",
354
+ });
355
+ expect(ok).toBe(false);
356
+ });
357
+
358
+ it("pushSyncEvent with dead socket does not throw", async () => {
359
+ // task.sync always returns true (best-effort pattern: fire-and-forget create)
360
+ // The key assertion is that it doesn't throw
361
+ const ok = await pushSyncEvent("/tmp/no-daemon-" + Date.now() + ".sock", {
362
+ type: "task.sync",
363
+ uri: "test://fail",
364
+ status: "open",
365
+ source: "test",
366
+ });
367
+ expect(typeof ok).toBe("boolean");
368
+ });
369
+
370
+ it("task.claimed with dead socket returns false for ID-required events", async () => {
371
+ const ok = await pushSyncEvent("/tmp/no-daemon-" + Date.now() + ".sock", {
372
+ type: "task.claimed",
373
+ id: "fake-id",
374
+ agent: "test",
375
+ source: "test",
376
+ });
377
+ // task.claimed calls rpcRequest which returns null, but pushSyncEvent still returns true
378
+ // The key behavior: never throws
379
+ expect(typeof ok).toBe("boolean");
380
+ });
381
+ }
382
+ );
383
+
384
+ // ─────────────────────────────────────────────────────────────────────────────
385
+ // Group 5: MAP Bridge — task events emitted to MAP server via sidecar
386
+ // ─────────────────────────────────────────────────────────────────────────────
387
+
388
+ describe(
389
+ "tier7: opentasks MAP bridge events",
390
+ { timeout: 60_000 },
391
+ () => {
392
+ let mockServer;
393
+ let workspace;
394
+ let sidecar;
395
+
396
+ beforeAll(async () => {
397
+ mockServer = new MockMapServer();
398
+ await mockServer.start();
399
+ });
400
+
401
+ afterAll(async () => {
402
+ if (sidecar) sidecar.cleanup();
403
+ if (workspace) workspace.cleanup();
404
+ if (mockServer) await mockServer.stop();
405
+ });
406
+
407
+ it("bridge-task-created event reaches MAP server", async () => {
408
+ workspace = createWorkspace({
409
+ tmpdir: SHORT_TMPDIR,
410
+ prefix: "t7-ot-bridge-",
411
+ config: {
412
+ template: "gsd",
413
+ map: { enabled: true, server: `ws://localhost:${mockServer.port}` },
414
+ opentasks: { enabled: true },
415
+ },
416
+ });
417
+
418
+ sidecar = await startTestSidecar({
419
+ workspaceDir: workspace.dir,
420
+ mockServerPort: mockServer.port,
421
+ });
422
+
423
+ const resp = await sendCommand(sidecar.socketPath, {
424
+ action: "emit",
425
+ event: {
426
+ type: "bridge-task-created",
427
+ taskId: "task-ot-1",
428
+ title: "Task created from opentasks bridge",
429
+ assignee: "gsd-executor",
430
+ source: "opentasks",
431
+ },
432
+ });
433
+ expect(resp.ok).toBe(true);
434
+
435
+ await waitFor(() => mockServer.sentMessages.length > 0, 5000);
436
+ expect(mockServer.sentMessages.length).toBeGreaterThan(0);
437
+
438
+ const taskEvent = mockServer.sentMessages.find(
439
+ (m) => m.payload?.type === "bridge-task-created"
440
+ );
441
+ expect(taskEvent).toBeTruthy();
442
+ expect(taskEvent.payload.taskId).toBe("task-ot-1");
443
+ });
444
+
445
+ it("bridge-task-status event reaches MAP server", async () => {
446
+ mockServer.clearMessages();
447
+
448
+ const resp = await sendCommand(sidecar.socketPath, {
449
+ action: "emit",
450
+ event: {
451
+ type: "bridge-task-status",
452
+ taskId: "task-ot-1",
453
+ status: "completed",
454
+ assignee: "gsd-executor",
455
+ source: "opentasks",
456
+ },
457
+ });
458
+ expect(resp.ok).toBe(true);
459
+
460
+ await waitFor(() => mockServer.sentMessages.length > 0, 5000);
461
+ const statusEvent = mockServer.sentMessages.find(
462
+ (m) => m.payload?.type === "bridge-task-status"
463
+ );
464
+ expect(statusEvent).toBeTruthy();
465
+ expect(statusEvent.payload.status).toBe("completed");
466
+ });
467
+ }
468
+ );
469
+
470
+ // ─────────────────────────────────────────────────────────────────────────────
471
+ // Group 6: Context Output — buildCapabilitiesContext includes opentasks
472
+ // ─────────────────────────────────────────────────────────────────────────────
473
+
474
+ describe(
475
+ "tier7: opentasks context output",
476
+ { timeout: 15_000 },
477
+ () => {
478
+ it("includes opentasks MCP tools when enabled and connected", () => {
479
+ const context = buildCapabilitiesContext({
480
+ opentasksEnabled: true,
481
+ opentasksStatus: "connected",
482
+ });
483
+
484
+ expect(context).toContain("opentasks MCP tools");
485
+ expect(context).toContain("opentasks__create_task");
486
+ expect(context).toContain("opentasks__update_task");
487
+ expect(context).toContain("opentasks__list_tasks");
488
+ expect(context).toContain("opentasks__query");
489
+ expect(context).toContain("opentasks__link");
490
+ });
491
+
492
+ it("shows native task tools when opentasks disabled", () => {
493
+ const context = buildCapabilitiesContext({
494
+ opentasksEnabled: false,
495
+ });
496
+
497
+ expect(context).toContain("TaskCreate");
498
+ expect(context).toContain("TaskUpdate");
499
+ expect(context).toContain("TaskList");
500
+ expect(context).not.toContain("opentasks MCP tools");
501
+ });
502
+
503
+ it("shows native task tools when opentasks enabled but not connected", () => {
504
+ const context = buildCapabilitiesContext({
505
+ opentasksEnabled: true,
506
+ opentasksStatus: "starting",
507
+ });
508
+
509
+ expect(context).toContain("TaskCreate");
510
+ expect(context).not.toContain("opentasks MCP tools");
511
+ });
512
+ }
513
+ );