claude-code-swarm 0.3.23 → 0.3.25

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 (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/docs/loadout-consumer-design.md +469 -0
  4. package/e2e/tier7-loadout-live.test.mjs +221 -0
  5. package/package.json +3 -3
  6. package/scripts/map-sidecar.mjs +34 -0
  7. package/scripts/scope-check.mjs +132 -0
  8. package/skills/swarm-mcp/SKILL.md +116 -0
  9. package/src/__tests__/cognitive-core-loadout-e2e.test.mjs +260 -0
  10. package/src/__tests__/e2e-loadout-demo.test.mjs +150 -0
  11. package/src/__tests__/fixtures/loadout-compile-team/loadouts/base-reviewer.yaml +16 -0
  12. package/src/__tests__/fixtures/loadout-compile-team/loadouts/extended-security.yaml +10 -0
  13. package/src/__tests__/fixtures/loadout-compile-team/roles/auditor.yaml +4 -0
  14. package/src/__tests__/fixtures/loadout-compile-team/roles/inline-extender.yaml +10 -0
  15. package/src/__tests__/fixtures/loadout-compile-team/roles/reviewer.yaml +4 -0
  16. package/src/__tests__/fixtures/loadout-compile-team/team.yaml +15 -0
  17. package/src/__tests__/loadout-materializer.test.mjs +578 -0
  18. package/src/__tests__/loadout-schema-bridge.test.mjs +177 -0
  19. package/src/__tests__/loadout-skilltree-compile-e2e.test.mjs +444 -0
  20. package/src/__tests__/loadout-template-shape.test.mjs +102 -0
  21. package/src/__tests__/mcp-health-checker.test.mjs +327 -0
  22. package/src/__tests__/scope-check.test.mjs +210 -0
  23. package/src/__tests__/skilltree-client.test.mjs +185 -1
  24. package/src/agent-generator.mjs +135 -8
  25. package/src/context-output.mjs +32 -0
  26. package/src/loadout-materializer.mjs +315 -0
  27. package/src/mcp-health-checker.mjs +237 -0
  28. package/src/opentasks-bridge.mjs +140 -0
  29. package/src/skilltree-client.mjs +135 -24
  30. package/src/template.mjs +158 -2
@@ -0,0 +1,315 @@
1
+ /**
2
+ * loadout-materializer.mjs — pure functions that convert openteams
3
+ * loadouts into Claude Code sub-agent frontmatter + sibling scope files.
4
+ *
5
+ * No filesystem writes in this module. All I/O is the caller's job.
6
+ * See docs/loadout-consumer-design.md for the full design.
7
+ *
8
+ * Inputs:
9
+ * - role — role object (at minimum: { name, description?, displayName? })
10
+ * - loadout — openteams ResolvedLoadout (may be undefined for loadout-less roles)
11
+ * - template — openteams ResolvedTemplate shape (mcpProviders as Map or plain object)
12
+ * - options — { teamName, projectPath, hookCommand, scopeFilePath, nativeTools, position }
13
+ *
14
+ * Output from materializeLoadout():
15
+ * {
16
+ * frontmatter: {...}, // YAML-serializable object for AGENT.md
17
+ * scopeFile: {...}, // JSON-serializable object for the scope-check hook
18
+ * warnings: [string], // non-fatal issues to surface to the user
19
+ * }
20
+ */
21
+
22
+ const MCP_TOOL_PREFIX = "mcp__";
23
+
24
+ /**
25
+ * Build the full materialization result for a role.
26
+ */
27
+ export function materializeLoadout({
28
+ role,
29
+ loadout,
30
+ template,
31
+ options = {},
32
+ } = {}) {
33
+ if (!role || typeof role !== "object" || !role.name) {
34
+ throw new Error("materializeLoadout: role.name is required");
35
+ }
36
+ const warnings = [];
37
+ const teamName = options.teamName ?? template?.manifest?.name ?? "team";
38
+
39
+ const mcp = resolveMcpScope({
40
+ mcpScope: loadout?.mcpScope ?? [],
41
+ mcpInstalls: loadout?.mcpServers ?? [],
42
+ providers: toProviderMap(template?.mcpProviders),
43
+ warnings,
44
+ roleName: role.name,
45
+ });
46
+
47
+ const scopeFile = buildScopeFile({
48
+ role,
49
+ loadout,
50
+ mcp,
51
+ teamName,
52
+ });
53
+
54
+ const frontmatter = buildFrontmatter({
55
+ role,
56
+ loadout,
57
+ teamName,
58
+ mcp,
59
+ scopeFilePath: options.scopeFilePath,
60
+ hookCommand: options.hookCommand,
61
+ projectPath: options.projectPath,
62
+ nativeTools: options.nativeTools ?? defaultNativeTools(role, options),
63
+ position: options.position,
64
+ });
65
+
66
+ return { frontmatter, scopeFile, warnings };
67
+ }
68
+
69
+ // ────────────────────────────────────────────────────────────────
70
+ // MCP scope resolution
71
+ // ────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Normalize mcpScope (from openteams) + install-bearing mcpServers
75
+ * into the shapes we need:
76
+ *
77
+ * mcpServers — list of `string | inline` for frontmatter
78
+ * disallowedTools — list of `mcp__<server>__<tool>` for frontmatter
79
+ * scope — canonical scope list for the scope file
80
+ * (normalized server → tools?/exclude?)
81
+ * refs — symbolic refs the consumer should resolve later
82
+ */
83
+ export function resolveMcpScope({
84
+ mcpScope = [],
85
+ mcpInstalls = [],
86
+ providers = new Map(),
87
+ warnings = [],
88
+ roleName = "",
89
+ } = {}) {
90
+ const mcpServers = [];
91
+ const seenServers = new Set();
92
+ const refs = [];
93
+
94
+ // Install-bearing entries authored by the loadout itself.
95
+ // Inline install specs spawn a subprocess per agent invocation —
96
+ // we emit them verbatim but warn users about the cost.
97
+ for (const install of mcpInstalls) {
98
+ if (install && typeof install === "object" && "ref" in install) {
99
+ refs.push({ ref: install.ref, config: install.config });
100
+ // Refs resolve lazily — don't add to mcpServers until resolved.
101
+ continue;
102
+ }
103
+ if (install && typeof install === "object" && "name" in install) {
104
+ if (seenServers.has(install.name)) continue;
105
+ seenServers.add(install.name);
106
+ const inline = { [install.name]: toInlineSpec(install) };
107
+ mcpServers.push(inline);
108
+ warnings.push(
109
+ `Role "${roleName}": loadout inlines MCP server "${install.name}" — ` +
110
+ `each subagent spawn starts its own subprocess. Consider moving to ` +
111
+ `team.mcp_providers instead.`
112
+ );
113
+ }
114
+ }
115
+
116
+ // Pure-scope references. A string-ref entry gives the agent access to
117
+ // the named server from the session-level base set.
118
+ for (const entry of mcpScope) {
119
+ if (!entry || !entry.server) continue;
120
+ const name = entry.server;
121
+ if (!seenServers.has(name)) {
122
+ seenServers.add(name);
123
+ mcpServers.push(name);
124
+ }
125
+ // Advisory: flag if neither providers declare it nor it's inline.
126
+ if (!providers.has(name)) {
127
+ // We don't definitively know whether the consumer has this server
128
+ // installed externally — that's handled by the health-check step.
129
+ // We only warn when providers expressly omit it; the caller can
130
+ // downgrade or clear the warning after checking against the active set.
131
+ // For now, don't push — health-checker owns cross-referencing.
132
+ }
133
+ }
134
+
135
+ // Translate exclude lists into literal `mcp__<server>__<tool>` denies.
136
+ const disallowedTools = [];
137
+ for (const entry of mcpScope) {
138
+ if (!entry?.exclude?.length) continue;
139
+ for (const tool of entry.exclude) {
140
+ disallowedTools.push(`${MCP_TOOL_PREFIX}${entry.server}__${tool}`);
141
+ }
142
+ }
143
+
144
+ // Build the canonical scope list for the scope file (hook input).
145
+ const scope = mcpScope.map((entry) => {
146
+ const out = { server: entry.server };
147
+ if (entry.tools?.length) out.tools = [...entry.tools];
148
+ if (entry.exclude?.length) out.exclude = [...entry.exclude];
149
+ return out;
150
+ });
151
+
152
+ return { mcpServers, disallowedTools, scope, refs };
153
+ }
154
+
155
+ function toInlineSpec(install) {
156
+ const spec = {};
157
+ if (install.command) spec.command = install.command;
158
+ if (install.args) spec.args = [...install.args];
159
+ if (install.env) spec.env = { ...install.env };
160
+ if (install.type) spec.type = install.type;
161
+ return spec;
162
+ }
163
+
164
+ /**
165
+ * Accept either a Map (openteams native) or a plain object (cached JSON)
166
+ * and return a Map for consistent lookup.
167
+ */
168
+ function toProviderMap(providers) {
169
+ if (!providers) return new Map();
170
+ if (providers instanceof Map) return providers;
171
+ return new Map(Object.entries(providers));
172
+ }
173
+
174
+ // ────────────────────────────────────────────────────────────────
175
+ // Scope file (hook input)
176
+ // ────────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Build the JSON payload the scope-check hook reads at runtime.
180
+ * Includes scope declarations + loadout permissions for enforcement.
181
+ */
182
+ export function buildScopeFile({ role, loadout, mcp, teamName }) {
183
+ const scopeFile = {
184
+ role: role.name,
185
+ team: teamName,
186
+ scope: mcp?.scope ?? [],
187
+ permissions: {
188
+ allow: loadout?.permissions?.allow ? [...loadout.permissions.allow] : [],
189
+ deny: loadout?.permissions?.deny ? [...loadout.permissions.deny] : [],
190
+ ask: loadout?.permissions?.ask ? [...loadout.permissions.ask] : [],
191
+ },
192
+ };
193
+ return scopeFile;
194
+ }
195
+
196
+ // ────────────────────────────────────────────────────────────────
197
+ // Frontmatter assembly
198
+ // ────────────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Assemble the frontmatter object for the AGENT.md file.
202
+ * Order of keys is deliberate for human readability when serialized.
203
+ */
204
+ export function buildFrontmatter({
205
+ role,
206
+ loadout,
207
+ teamName,
208
+ mcp,
209
+ scopeFilePath,
210
+ hookCommand,
211
+ projectPath,
212
+ nativeTools,
213
+ position,
214
+ }) {
215
+ const name = `${teamName}-${role.name}`;
216
+ const description =
217
+ role.description ||
218
+ loadout?.description ||
219
+ `${role.name} role for the ${teamName} team`;
220
+
221
+ const fm = {
222
+ name,
223
+ description,
224
+ team_name: teamName,
225
+ role: role.name,
226
+ generated_by: "claude-code-swarm",
227
+ generated_at: new Date().toISOString(),
228
+ };
229
+ if (projectPath) fm.project_path = projectPath;
230
+ if (position) fm.position = position;
231
+
232
+ if (nativeTools?.length) fm.tools = [...nativeTools];
233
+
234
+ if (mcp?.mcpServers?.length) fm.mcpServers = mcp.mcpServers;
235
+
236
+ if (mcp?.disallowedTools?.length) {
237
+ fm.disallowedTools = mcp.disallowedTools;
238
+ }
239
+
240
+ const needsHook = scopeNeedsHook(mcp, loadout);
241
+ if (needsHook && hookCommand && scopeFilePath) {
242
+ fm.hooks = buildHookDeclaration({
243
+ hookCommand,
244
+ scopeFilePath,
245
+ roleName: role.name,
246
+ });
247
+ }
248
+
249
+ if (loadout?.capabilities?.length) {
250
+ fm.capabilities = [...loadout.capabilities];
251
+ }
252
+
253
+ return fm;
254
+ }
255
+
256
+ /**
257
+ * We only emit a hook when there's something the hook can enforce that
258
+ * the static frontmatter can't — namely tool-level allowlists (no
259
+ * wildcard support in `tools:`) or non-empty permissions lists that
260
+ * aren't already materialized elsewhere.
261
+ */
262
+ export function scopeNeedsHook(mcp, loadout) {
263
+ if (!mcp && !loadout) return false;
264
+ if (mcp?.scope?.some((s) => s.tools?.length)) return true;
265
+ if (
266
+ (loadout?.permissions?.allow?.length ?? 0) > 0 ||
267
+ (loadout?.permissions?.deny?.length ?? 0) > 0 ||
268
+ (loadout?.permissions?.ask?.length ?? 0) > 0
269
+ ) {
270
+ return true;
271
+ }
272
+ return false;
273
+ }
274
+
275
+ function buildHookDeclaration({ hookCommand, scopeFilePath, roleName }) {
276
+ return {
277
+ PreToolUse: [
278
+ {
279
+ matcher: `${MCP_TOOL_PREFIX}.*`,
280
+ hooks: [
281
+ {
282
+ type: "command",
283
+ command: hookCommand,
284
+ env: {
285
+ SCOPE_FILE: scopeFilePath,
286
+ ROLE_NAME: roleName,
287
+ },
288
+ },
289
+ ],
290
+ },
291
+ ],
292
+ };
293
+ }
294
+
295
+ // ────────────────────────────────────────────────────────────────
296
+ // Native tools defaulting
297
+ // ────────────────────────────────────────────────────────────────
298
+
299
+ /**
300
+ * Mirror the existing agent-generator.mjs behavior for default tools.
301
+ * Callers can override via options.nativeTools if they've already
302
+ * computed tools through another code path.
303
+ */
304
+ function defaultNativeTools(role, options = {}) {
305
+ const tools = ["Read", "Glob", "Grep", "Bash"];
306
+ if (options.opentasksEnabled) {
307
+ tools.push("SendMessage");
308
+ } else {
309
+ tools.push("TaskList", "TaskUpdate", "SendMessage");
310
+ if (options.position === "root" || options.position === "companion") {
311
+ tools.push("TaskCreate");
312
+ }
313
+ }
314
+ return tools;
315
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * mcp-health-checker.mjs — compare declared MCP providers against the
3
+ * set of servers known to be installed, produce a report, and format it
4
+ * for human consumption.
5
+ *
6
+ * Two entry points:
7
+ * checkMcpHealth({ providers, activeSet, scopeReferences }) — pure
8
+ * discoverActiveSet({ projectPath, pluginPath, userPath }) — reads fs
9
+ *
10
+ * See docs/loadout-consumer-design.md for the non-invasive design.
11
+ */
12
+
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import os from "os";
16
+
17
+ /**
18
+ * Compare declared providers against the active set and return a report.
19
+ * Pure function — no I/O. Consumers supply the active-set via discoverActiveSet
20
+ * or their own logic (e.g. hive DB lookup).
21
+ *
22
+ * @param providers Map<string, McpProviderSpec> or plain object from team.yaml
23
+ * @param activeSet Map<string, { source, spec? }> of installed servers
24
+ * @param scopeReferences [{ loadout, server }] — which loadouts reference which servers
25
+ * @returns {MCPHealthReport}
26
+ */
27
+ export function checkMcpHealth({
28
+ providers,
29
+ activeSet = new Map(),
30
+ scopeReferences = [],
31
+ } = {}) {
32
+ const providerMap = toMap(providers);
33
+ const activeMap = toMap(activeSet);
34
+
35
+ const ok = [];
36
+ const missing = [];
37
+ const disabled = [];
38
+ const refs = [];
39
+
40
+ for (const [name, spec] of providerMap) {
41
+ if (spec?.disabled) {
42
+ disabled.push({ name, spec });
43
+ continue;
44
+ }
45
+ if (spec?.ref) {
46
+ refs.push({ name, ref: spec.ref, spec });
47
+ continue;
48
+ }
49
+ const active = activeMap.get(name);
50
+ if (active) {
51
+ ok.push({ name, source: active.source, spec });
52
+ } else {
53
+ missing.push({ name, spec });
54
+ }
55
+ }
56
+
57
+ // Active servers that are NOT declared in providers — informational only.
58
+ // Useful for detecting plugin MCPs or user-installed servers the team
59
+ // template wasn't aware of but can still reference in scope.
60
+ const activeOnly = [];
61
+ for (const [name, active] of activeMap) {
62
+ if (!providerMap.has(name)) {
63
+ activeOnly.push({ name, source: active.source });
64
+ }
65
+ }
66
+
67
+ // Scope references that aren't backed by providers OR active servers.
68
+ // These are the "loadout X wants server Y but it's not available" cases.
69
+ const orphanedReferences = [];
70
+ const availableSet = new Set([...providerMap.keys(), ...activeMap.keys()]);
71
+ for (const ref of scopeReferences) {
72
+ if (!ref?.server) continue;
73
+ if (!availableSet.has(ref.server)) {
74
+ orphanedReferences.push({ loadout: ref.loadout, server: ref.server });
75
+ }
76
+ }
77
+
78
+ return {
79
+ ok,
80
+ missing,
81
+ disabled,
82
+ refs,
83
+ activeOnly,
84
+ orphanedReferences,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Collect the set of MCP servers known to be installed by reading the
90
+ * well-known Claude Code configuration paths.
91
+ *
92
+ * Priority order (later wins on conflict):
93
+ * 1. Plugin MCPs (.claude-plugin/plugin.json:mcpServers)
94
+ * 2. User MCPs (~/.claude/mcp.json:mcpServers or ~/.claude.json:mcpServers)
95
+ * 3. Project MCPs (.mcp.json:mcpServers)
96
+ *
97
+ * Returns a Map<string, { source, spec }> describing which server came
98
+ * from where. Silently skips files that don't exist or fail to parse —
99
+ * this is read-only observation, not config validation.
100
+ */
101
+ export function discoverActiveSet({
102
+ projectPath = process.cwd(),
103
+ pluginPath,
104
+ userHome = os.homedir(),
105
+ } = {}) {
106
+ const active = new Map();
107
+
108
+ // 1. Plugin MCPs — lowest priority
109
+ if (pluginPath) {
110
+ const pluginManifest = path.join(pluginPath, ".claude-plugin", "plugin.json");
111
+ mergeMcpServers(active, pluginManifest, "plugin");
112
+ }
113
+
114
+ // 2. User MCPs — middle priority
115
+ const userMcp = path.join(userHome, ".claude", "mcp.json");
116
+ mergeMcpServers(active, userMcp, "user");
117
+
118
+ // 3. Project MCPs — highest priority
119
+ const projectMcp = path.join(projectPath, ".mcp.json");
120
+ mergeMcpServers(active, projectMcp, "project");
121
+
122
+ return active;
123
+ }
124
+
125
+ /**
126
+ * Scan a loadouts map for every scope reference — useful input for
127
+ * checkMcpHealth's scopeReferences arg. Accepts either openteams' native
128
+ * Map<name, ResolvedLoadout> or a plain object of the same shape.
129
+ */
130
+ export function collectScopeReferences(loadouts) {
131
+ const out = [];
132
+ const it = toMap(loadouts);
133
+ for (const [name, lo] of it) {
134
+ const scope = lo?.mcpScope ?? [];
135
+ for (const entry of scope) {
136
+ if (entry?.server) {
137
+ out.push({ loadout: name, server: entry.server });
138
+ }
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // ────────────────────────────────────────────────────────────────
145
+ // Formatting — human-readable report
146
+ // ────────────────────────────────────────────────────────────────
147
+
148
+ const STATUS = {
149
+ ok: "✓",
150
+ missing: "⚠",
151
+ refs: "◎",
152
+ disabled: "○",
153
+ };
154
+
155
+ /**
156
+ * Render a report as a text block suitable for terminal output.
157
+ * ASCII-only — no terminal-color escapes (callers can colorize).
158
+ */
159
+ export function formatHealthReport(report, { teamName = "team" } = {}) {
160
+ const lines = [];
161
+ lines.push(`Team "${teamName}" MCP status`);
162
+ lines.push("─".repeat(40));
163
+
164
+ if (
165
+ report.ok.length === 0 &&
166
+ report.missing.length === 0 &&
167
+ report.refs.length === 0 &&
168
+ report.disabled.length === 0
169
+ ) {
170
+ lines.push("(no MCP providers declared; team may use installed servers)");
171
+ }
172
+
173
+ for (const e of report.ok) {
174
+ lines.push(` ${STATUS.ok} ${e.name} (${e.source})`);
175
+ }
176
+ for (const e of report.missing) {
177
+ lines.push(` ${STATUS.missing} ${e.name} (declared, not active)`);
178
+ lines.push(` → /swarm mcp install ${e.name}`);
179
+ }
180
+ for (const e of report.refs) {
181
+ lines.push(` ${STATUS.refs} ${e.name} (ref: ${e.ref})`);
182
+ lines.push(` → ref resolution deferred`);
183
+ }
184
+ for (const e of report.disabled) {
185
+ lines.push(` ${STATUS.disabled} ${e.name} (disabled)`);
186
+ }
187
+
188
+ if (report.orphanedReferences.length > 0) {
189
+ lines.push("");
190
+ lines.push("Loadout scope references not backed by any provider:");
191
+ for (const r of report.orphanedReferences) {
192
+ lines.push(` - loadout "${r.loadout}" uses "${r.server}"`);
193
+ }
194
+ }
195
+
196
+ if (report.activeOnly.length > 0) {
197
+ lines.push("");
198
+ lines.push(`Active servers not declared: ${report.activeOnly.map((a) => a.name).join(", ")}`);
199
+ }
200
+
201
+ return lines.join("\n");
202
+ }
203
+
204
+ // ────────────────────────────────────────────────────────────────
205
+ // Internals
206
+ // ────────────────────────────────────────────────────────────────
207
+
208
+ function toMap(input) {
209
+ if (!input) return new Map();
210
+ if (input instanceof Map) return input;
211
+ return new Map(Object.entries(input));
212
+ }
213
+
214
+ function mergeMcpServers(active, jsonPath, source) {
215
+ if (!fs.existsSync(jsonPath)) return;
216
+ let doc;
217
+ try {
218
+ doc = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
219
+ } catch {
220
+ return;
221
+ }
222
+ const mcp = doc?.mcpServers;
223
+ if (!mcp || typeof mcp !== "object") return;
224
+ for (const [name, spec] of Object.entries(mcp)) {
225
+ active.set(name, { source, spec });
226
+ }
227
+ }
228
+
229
+ /**
230
+ * @typedef {Object} MCPHealthReport
231
+ * @property {Array<{name: string, source: string, spec: object}>} ok
232
+ * @property {Array<{name: string, spec: object}>} missing
233
+ * @property {Array<{name: string, spec: object}>} disabled
234
+ * @property {Array<{name: string, ref: string, spec: object}>} refs
235
+ * @property {Array<{name: string, source: string}>} activeOnly
236
+ * @property {Array<{loadout: string, server: string}>} orphanedReferences
237
+ */
@@ -0,0 +1,140 @@
1
+ /**
2
+ * opentasks-bridge.mjs — Opentasks MAP event bridge for the sidecar
3
+ *
4
+ * Attaches opentasks' `createMAPEventBridge` to the local daemon's watch
5
+ * stream so every graph change surfaces as a `task.*` / `context.*` MAP
6
+ * event over the shared MAP connection. The bridge is kind-agnostic for
7
+ * contexts — downstream consumers (e.g. OpenHive's hub) route by
8
+ * `metadata.kind` to classify specs vs plain contexts.
9
+ *
10
+ * This is the "Option A" daemon-wired path — no explicit `bridge-*`
11
+ * sidecar command is needed for contexts. For tasks, the existing
12
+ * PostToolUse(TaskCreate) → `bridge-task-*` command chain remains the
13
+ * active path (matches the filters in sidecar-server.mjs and avoids
14
+ * double-emission when both hooks and the watcher fire for the same
15
+ * change).
16
+ *
17
+ * Extracted from map-sidecar.mjs for testability.
18
+ */
19
+
20
+ import { createLogger } from "./log.mjs";
21
+
22
+ const log = createLogger("opentasks-bridge");
23
+
24
+ /**
25
+ * Start the opentasks MAP event bridge.
26
+ *
27
+ * Connects to the local opentasks daemon, subscribes to graph changes,
28
+ * and forwards every event through the MAP event bridge so connected
29
+ * observers (OpenHive hub, peer swarms) see them as standard MAP events.
30
+ *
31
+ * Safe to call when the daemon isn't running or when MAP connection is
32
+ * absent — returns `null` and logs at debug level.
33
+ *
34
+ * @param {object} conn - MAP connection (AgentConnection or MeshPeer connection)
35
+ * @param {object} options
36
+ * @param {string} options.scope - MAP scope (e.g. "swarm:gsd")
37
+ * @param {() => void} [options.onActivity] - Called on each bridged event
38
+ * @param {() => Promise<object>} [options.importOpentasks] - Override for `await import("opentasks")`
39
+ * @param {() => Promise<object>} [options.importOpentasksClient] - Override for `./opentasks-client.mjs` import
40
+ * @returns {Promise<{ stop: () => Promise<void> } | null>}
41
+ */
42
+ export async function startOpenTasksEventBridge(conn, options = {}) {
43
+ if (!conn) return null;
44
+
45
+ const {
46
+ scope = "swarm:default",
47
+ onActivity,
48
+ importOpentasks,
49
+ importOpentasksClient,
50
+ } = options;
51
+
52
+ let opentasks;
53
+ try {
54
+ opentasks = importOpentasks
55
+ ? await importOpentasks()
56
+ : await import("opentasks");
57
+ } catch (err) {
58
+ log.debug("opentasks package not available", { error: err.message });
59
+ return null;
60
+ }
61
+
62
+ const { createMAPEventBridge, createIPCClient } = opentasks || {};
63
+ if (!createMAPEventBridge || !createIPCClient) {
64
+ log.debug("opentasks event-bridge exports missing");
65
+ return null;
66
+ }
67
+
68
+ let socketPath;
69
+ try {
70
+ const opentasksClient = importOpentasksClient
71
+ ? await importOpentasksClient()
72
+ : await import("./opentasks-client.mjs");
73
+ socketPath = opentasksClient.findSocketPath();
74
+ } catch (err) {
75
+ log.debug("could not resolve opentasks socket path", { error: err.message });
76
+ return null;
77
+ }
78
+
79
+ const client = createIPCClient(socketPath);
80
+ try {
81
+ await client.connect();
82
+ } catch (err) {
83
+ log.debug("opentasks daemon not reachable, bridge disabled", {
84
+ socketPath,
85
+ error: err.message,
86
+ });
87
+ return null;
88
+ }
89
+
90
+ const bridge = createMAPEventBridge({
91
+ connection: conn,
92
+ scope,
93
+ agentId: `${scope}-sidecar`,
94
+ // Suppress bridge task.* events — the sidecar's existing
95
+ // `bridge-task-*` command chain (driven by PostToolUse hooks) is the
96
+ // canonical path for tasks. Emitting here too would duplicate every
97
+ // task event. Contexts have no hook counterpart, so they only flow
98
+ // via this watcher.
99
+ filter: (type) => !type.startsWith("task."),
100
+ });
101
+
102
+ const offNotif = client.onNotification((method, params) => {
103
+ if (method !== "watch.event") return;
104
+ if (onActivity) onActivity();
105
+ log.debug("watch.event received", {
106
+ kind: params?.type,
107
+ nodeId: params?.nodeId,
108
+ nodeType: params?.node?.type,
109
+ });
110
+ try {
111
+ bridge.handleProviderChange("native", { kind: "node", event: params });
112
+ } catch (err) {
113
+ log.debug("bridge.handleProviderChange threw", { error: err.message });
114
+ }
115
+ });
116
+
117
+ try {
118
+ await client.request("watch.subscribe", {});
119
+ } catch (err) {
120
+ log.debug("watch.subscribe failed, bridge disabled", { error: err.message });
121
+ offNotif();
122
+ try { client.disconnect(); } catch { /* ignore */ }
123
+ return null;
124
+ }
125
+
126
+ log.info("opentasks event bridge active", { scope, socketPath });
127
+
128
+ return {
129
+ async stop() {
130
+ try {
131
+ await client.request("watch.unsubscribe", {});
132
+ } catch {
133
+ // ignore — we're shutting down anyway
134
+ }
135
+ offNotif();
136
+ bridge.stop();
137
+ try { client.disconnect(); } catch { /* ignore */ }
138
+ },
139
+ };
140
+ }