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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/docs/loadout-consumer-design.md +469 -0
- package/e2e/tier7-loadout-live.test.mjs +221 -0
- package/package.json +3 -3
- package/scripts/map-sidecar.mjs +34 -0
- package/scripts/scope-check.mjs +132 -0
- package/skills/swarm-mcp/SKILL.md +116 -0
- package/src/__tests__/cognitive-core-loadout-e2e.test.mjs +260 -0
- package/src/__tests__/e2e-loadout-demo.test.mjs +150 -0
- package/src/__tests__/fixtures/loadout-compile-team/loadouts/base-reviewer.yaml +16 -0
- package/src/__tests__/fixtures/loadout-compile-team/loadouts/extended-security.yaml +10 -0
- package/src/__tests__/fixtures/loadout-compile-team/roles/auditor.yaml +4 -0
- package/src/__tests__/fixtures/loadout-compile-team/roles/inline-extender.yaml +10 -0
- package/src/__tests__/fixtures/loadout-compile-team/roles/reviewer.yaml +4 -0
- package/src/__tests__/fixtures/loadout-compile-team/team.yaml +15 -0
- package/src/__tests__/loadout-materializer.test.mjs +578 -0
- package/src/__tests__/loadout-schema-bridge.test.mjs +177 -0
- package/src/__tests__/loadout-skilltree-compile-e2e.test.mjs +444 -0
- package/src/__tests__/loadout-template-shape.test.mjs +102 -0
- package/src/__tests__/mcp-health-checker.test.mjs +327 -0
- package/src/__tests__/scope-check.test.mjs +210 -0
- package/src/__tests__/skilltree-client.test.mjs +185 -1
- package/src/agent-generator.mjs +135 -8
- package/src/context-output.mjs +32 -0
- package/src/loadout-materializer.mjs +315 -0
- package/src/mcp-health-checker.mjs +237 -0
- package/src/opentasks-bridge.mjs +140 -0
- package/src/skilltree-client.mjs +135 -24
- 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
|
+
}
|