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,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 7: Live Loadout E2E Tests
|
|
3
|
+
*
|
|
4
|
+
* Full end-to-end tests with a LIVE Claude Code instance to verify the
|
|
5
|
+
* loadout-consumer integration designed in docs/loadout-consumer-design.md.
|
|
6
|
+
*
|
|
7
|
+
* These tests cover the Claude-Code-side unknowns that pure-function +
|
|
8
|
+
* subprocess tests cannot prove:
|
|
9
|
+
* 1. Claude Code discovers file-based sub-agents at `.claude/agents/`
|
|
10
|
+
* with the rich frontmatter we generate (mcpServers, hooks, etc.)
|
|
11
|
+
* 3. PreToolUse hooks declared in sub-agent frontmatter fire, and the
|
|
12
|
+
* `env:` field propagates to the hook subprocess. This is the
|
|
13
|
+
* critical Open Verification #3 from the design doc.
|
|
14
|
+
*
|
|
15
|
+
* Gated behind LIVE_AGENT_TEST=1 — makes real LLM calls (~$1-2 per run).
|
|
16
|
+
*
|
|
17
|
+
* Run:
|
|
18
|
+
* LIVE_AGENT_TEST=1 npx vitest run --config e2e/vitest.config.e2e.mjs \
|
|
19
|
+
* e2e/tier7-loadout-live.test.mjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
23
|
+
import fs from "fs";
|
|
24
|
+
import path from "path";
|
|
25
|
+
import { execFileSync } from "child_process";
|
|
26
|
+
import { fileURLToPath } from "url";
|
|
27
|
+
import { runClaude, CLI_AVAILABLE, PLUGIN_DIR } from "./helpers/cli.mjs";
|
|
28
|
+
import { createWorkspace } from "./helpers/workspace.mjs";
|
|
29
|
+
import { cleanupWorkspace } from "./helpers/cleanup.mjs";
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const LIVE = !!process.env.LIVE_AGENT_TEST;
|
|
33
|
+
|
|
34
|
+
const LOADOUT_DEMO = path.resolve(
|
|
35
|
+
__dirname,
|
|
36
|
+
"..",
|
|
37
|
+
"..",
|
|
38
|
+
"openteams",
|
|
39
|
+
"examples",
|
|
40
|
+
"loadout-demo"
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const DEMO_AVAILABLE = fs.existsSync(LOADOUT_DEMO);
|
|
44
|
+
|
|
45
|
+
// ────────────────────────────────────────────────────────────────
|
|
46
|
+
// Test 1 — Claude Code discovers generated AGENT.md sub-agents
|
|
47
|
+
// ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe.skipIf(!LIVE || !CLI_AVAILABLE || !DEMO_AVAILABLE)(
|
|
50
|
+
"tier7: loadout live — file-based sub-agent discovery",
|
|
51
|
+
{ timeout: 180_000 },
|
|
52
|
+
() => {
|
|
53
|
+
let workspace;
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
workspace = createWorkspace({
|
|
57
|
+
prefix: "swarm-loadout-live-disco-",
|
|
58
|
+
config: { template: LOADOUT_DEMO },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Materialize the loadout-demo team into .claude/agents/
|
|
62
|
+
const agentsDir = path.join(workspace.dir, ".claude", "agents");
|
|
63
|
+
execFileSync(
|
|
64
|
+
"node",
|
|
65
|
+
[
|
|
66
|
+
path.join(PLUGIN_DIR, "scripts", "generate-agents.mjs"),
|
|
67
|
+
LOADOUT_DEMO,
|
|
68
|
+
agentsDir,
|
|
69
|
+
],
|
|
70
|
+
{ stdio: "ignore", timeout: 30_000 }
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(() => {
|
|
75
|
+
if (workspace) cleanupWorkspace(workspace.dir);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("writes AGENT.md files with expected names", () => {
|
|
79
|
+
const agentsDir = path.join(workspace.dir, ".claude", "agents");
|
|
80
|
+
expect(
|
|
81
|
+
fs.existsSync(path.join(agentsDir, "reviewer", "AGENT.md"))
|
|
82
|
+
).toBe(true);
|
|
83
|
+
expect(
|
|
84
|
+
fs.existsSync(path.join(agentsDir, "implementer", "AGENT.md"))
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("Claude Code accepts the frontmatter and lists our sub-agents", async () => {
|
|
89
|
+
const { messages, result, logFile } = await runClaude(
|
|
90
|
+
"List the names of all sub-agents available to you in this project " +
|
|
91
|
+
"(look at .claude/agents/). Respond with a comma-separated list " +
|
|
92
|
+
"of just the sub-agent names, nothing else.",
|
|
93
|
+
{
|
|
94
|
+
cwd: workspace.dir,
|
|
95
|
+
maxTurns: 5,
|
|
96
|
+
maxBudgetUsd: "0.30",
|
|
97
|
+
label: "tier7-disco",
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(result, `see log: ${logFile}`).toBeTruthy();
|
|
102
|
+
const resultText = JSON.stringify(result || {}) + JSON.stringify(messages);
|
|
103
|
+
|
|
104
|
+
// The generated sub-agents should appear in the response.
|
|
105
|
+
// Claude Code auto-resolves .claude/agents/<dir>/AGENT.md as <dir>.
|
|
106
|
+
expect(
|
|
107
|
+
resultText,
|
|
108
|
+
`Expected Claude to list sub-agents; see log: ${logFile}`
|
|
109
|
+
).toMatch(/reviewer/i);
|
|
110
|
+
expect(resultText).toMatch(/implementer/i);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// ────────────────────────────────────────────────────────────────
|
|
116
|
+
// Test 3 — PreToolUse hook with `env:` propagates to subprocess
|
|
117
|
+
//
|
|
118
|
+
// This is the critical Open Verification from the design doc.
|
|
119
|
+
// If this passes, the whole non-invasive scope-check enforcement
|
|
120
|
+
// strategy works. If it fails, we need a fallback (CLI args instead
|
|
121
|
+
// of env vars in the hook command).
|
|
122
|
+
// ────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe.skipIf(!LIVE || !CLI_AVAILABLE)(
|
|
125
|
+
"tier7: loadout live — PreToolUse hook env: in sub-agent frontmatter",
|
|
126
|
+
{ timeout: 180_000 },
|
|
127
|
+
() => {
|
|
128
|
+
let workspace;
|
|
129
|
+
let probePath;
|
|
130
|
+
let hookScriptPath;
|
|
131
|
+
|
|
132
|
+
beforeAll(async () => {
|
|
133
|
+
workspace = createWorkspace({
|
|
134
|
+
prefix: "swarm-loadout-live-hook-",
|
|
135
|
+
config: { template: "" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
probePath = path.join(workspace.dir, "hook-probe.log");
|
|
139
|
+
hookScriptPath = path.join(workspace.dir, "log-env.sh");
|
|
140
|
+
|
|
141
|
+
// Hook script: write the env vars we care about to a probe file.
|
|
142
|
+
// Exit 0 (allow) so the tool call proceeds normally.
|
|
143
|
+
fs.writeFileSync(
|
|
144
|
+
hookScriptPath,
|
|
145
|
+
[
|
|
146
|
+
"#!/usr/bin/env bash",
|
|
147
|
+
`echo "PROBE_KEY=\${PROBE_KEY:-MISSING}" >> "${probePath}"`,
|
|
148
|
+
`echo "ROLE_NAME=\${ROLE_NAME:-MISSING}" >> "${probePath}"`,
|
|
149
|
+
`echo "--tool=\${CLAUDE_TOOL_NAME:-?}" >> "${probePath}"`,
|
|
150
|
+
"exit 0",
|
|
151
|
+
].join("\n"),
|
|
152
|
+
{ mode: 0o755 }
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Write a minimal sub-agent with a Bash PreToolUse hook that
|
|
156
|
+
// uses env: to inject PROBE_KEY and ROLE_NAME.
|
|
157
|
+
const agentDir = path.join(workspace.dir, ".claude", "agents");
|
|
158
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
159
|
+
const agentMd = [
|
|
160
|
+
"---",
|
|
161
|
+
"name: probe-agent",
|
|
162
|
+
'description: "Probe agent for hook env: verification"',
|
|
163
|
+
"tools:",
|
|
164
|
+
" - Bash",
|
|
165
|
+
"hooks:",
|
|
166
|
+
" PreToolUse:",
|
|
167
|
+
' - matcher: "Bash"',
|
|
168
|
+
" hooks:",
|
|
169
|
+
" - type: command",
|
|
170
|
+
` command: ${hookScriptPath}`,
|
|
171
|
+
" env:",
|
|
172
|
+
" PROBE_KEY: probe-value-xyz-42",
|
|
173
|
+
" ROLE_NAME: probe-role",
|
|
174
|
+
"---",
|
|
175
|
+
"",
|
|
176
|
+
"# Probe Agent",
|
|
177
|
+
"",
|
|
178
|
+
"When asked to run a shell command, run it via Bash. No commentary.",
|
|
179
|
+
"",
|
|
180
|
+
].join("\n");
|
|
181
|
+
fs.writeFileSync(path.join(agentDir, "probe-agent.md"), agentMd);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterAll(() => {
|
|
185
|
+
if (workspace) cleanupWorkspace(workspace.dir);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("invokes a Bash-using sub-agent and captures hook env vars", async () => {
|
|
189
|
+
const { messages, result, logFile } = await runClaude(
|
|
190
|
+
"Use the Task tool to delegate to sub-agent 'probe-agent' with the task: " +
|
|
191
|
+
"\"Run the shell command: echo hello-from-probe\"",
|
|
192
|
+
{
|
|
193
|
+
cwd: workspace.dir,
|
|
194
|
+
maxTurns: 6,
|
|
195
|
+
maxBudgetUsd: "0.30",
|
|
196
|
+
label: "tier7-hook-env",
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(result, `see log: ${logFile}`).toBeTruthy();
|
|
201
|
+
|
|
202
|
+
// The hook should have fired at least once — probe log must exist and
|
|
203
|
+
// contain the specific env value we set in the sub-agent frontmatter.
|
|
204
|
+
expect(
|
|
205
|
+
fs.existsSync(probePath),
|
|
206
|
+
`Hook probe log missing at ${probePath}. See runClaude log: ${logFile}. ` +
|
|
207
|
+
`This is the key assertion — if the file is absent, Claude Code did not ` +
|
|
208
|
+
`invoke the PreToolUse hook from the sub-agent's frontmatter.`
|
|
209
|
+
).toBe(true);
|
|
210
|
+
|
|
211
|
+
const probe = fs.readFileSync(probePath, "utf-8");
|
|
212
|
+
expect(
|
|
213
|
+
probe,
|
|
214
|
+
`Env var PROBE_KEY did not propagate. Probe contents: ${probe}. ` +
|
|
215
|
+
`Log: ${logFile}. This means Claude Code does not honor \`env:\` in ` +
|
|
216
|
+
`sub-agent hook frontmatter, and we need the CLI-arg fallback.`
|
|
217
|
+
).toContain("PROBE_KEY=probe-value-xyz-42");
|
|
218
|
+
expect(probe).toContain("ROLE_NAME=probe-role");
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-swarm",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.25",
|
|
4
4
|
"description": "Claude Code plugin for launching agent teams from openteams topologies with MAP observability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"agent-inbox": "^0.1.9",
|
|
57
57
|
"minimem": "^0.1.1",
|
|
58
|
-
"opentasks": "^0.1.
|
|
59
|
-
"skill-tree": "^0.
|
|
58
|
+
"opentasks": "^0.1.2",
|
|
59
|
+
"skill-tree": "^0.2.0",
|
|
60
60
|
"vitest": "^4.0.18",
|
|
61
61
|
"ws": "^8.0.0"
|
|
62
62
|
}
|
package/scripts/map-sidecar.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { SOCKET_PATH, PID_PATH, INBOX_SOCKET_PATH, sessionPaths, pluginDir } fro
|
|
|
24
24
|
import { connectToMAP } from "../src/map-connection.mjs";
|
|
25
25
|
import { createMeshPeer, createMeshInbox } from "../src/mesh-connection.mjs";
|
|
26
26
|
import { createSocketServer, createCommandHandler } from "../src/sidecar-server.mjs";
|
|
27
|
+
import { startOpenTasksEventBridge } from "../src/opentasks-bridge.mjs";
|
|
27
28
|
import { createContentProvider } from "../src/content-provider.mjs";
|
|
28
29
|
import { startMemoryWatcher } from "../src/memory-watcher.mjs";
|
|
29
30
|
import { readConfig } from "../src/config.mjs";
|
|
@@ -123,6 +124,7 @@ let inboxInstance = null;
|
|
|
123
124
|
let inactivityTimer = null;
|
|
124
125
|
let reconnectInterval = null;
|
|
125
126
|
let transportMode = "websocket"; // "mesh" or "websocket"
|
|
127
|
+
let opentasksBridge = null; // Daemon watch → MAP event bridge (Option A)
|
|
126
128
|
const registeredAgents = new Map();
|
|
127
129
|
|
|
128
130
|
// ── Inactivity Timer ────────────────────────────────────────────────────────
|
|
@@ -143,6 +145,13 @@ async function shutdown() {
|
|
|
143
145
|
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
144
146
|
if (reconnectInterval) clearInterval(reconnectInterval);
|
|
145
147
|
|
|
148
|
+
// Stop opentasks event bridge before the MAP connection drops — the
|
|
149
|
+
// bridge needs a live connection to send its unsubscribe over.
|
|
150
|
+
if (opentasksBridge) {
|
|
151
|
+
try { await opentasksBridge.stop(); } catch { /* ignore */ }
|
|
152
|
+
opentasksBridge = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
// Stop agent-inbox first (it borrows the connection/peer, doesn't own it)
|
|
147
156
|
if (inboxInstance) {
|
|
148
157
|
try { await inboxInstance.stop(); } catch { /* ignore */ }
|
|
@@ -242,6 +251,19 @@ function startSlowReconnectLoop() {
|
|
|
242
251
|
// Re-subscribe inbox events to the new connection
|
|
243
252
|
subscribeInboxEvents(newConn);
|
|
244
253
|
|
|
254
|
+
// Re-attach opentasks event bridge to the fresh connection —
|
|
255
|
+
// the previous bridge was bound to the dead one.
|
|
256
|
+
if (opentasksBridge) {
|
|
257
|
+
try { await opentasksBridge.stop(); } catch { /* ignore */ }
|
|
258
|
+
opentasksBridge = null;
|
|
259
|
+
}
|
|
260
|
+
if (PROJECT_CONTEXT.task_graph) {
|
|
261
|
+
opentasksBridge = await startOpenTasksEventBridge(newConn, {
|
|
262
|
+
scope: MAP_SCOPE,
|
|
263
|
+
onActivity: resetInactivityTimer,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
245
267
|
log.info("reconnected to MAP server");
|
|
246
268
|
}
|
|
247
269
|
} catch (err) {
|
|
@@ -428,6 +450,18 @@ async function startWebSocketTransport() {
|
|
|
428
450
|
await registerOpenTasksHandler(connection);
|
|
429
451
|
}
|
|
430
452
|
|
|
453
|
+
// Start the opentasks → MAP event bridge — surfaces every graph
|
|
454
|
+
// change (context/spec nodes in particular) as a MAP event over the
|
|
455
|
+
// shared connection. Task-event emission is suppressed inside the
|
|
456
|
+
// bridge to avoid double-sending alongside the existing PostToolUse
|
|
457
|
+
// `bridge-task-*` command chain.
|
|
458
|
+
if (connection && PROJECT_CONTEXT.task_graph) {
|
|
459
|
+
opentasksBridge = await startOpenTasksEventBridge(connection, {
|
|
460
|
+
scope: MAP_SCOPE,
|
|
461
|
+
onActivity: resetInactivityTimer,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
431
465
|
// Start agent-inbox with MAP connection (legacy mode)
|
|
432
466
|
if (INBOX_CONFIG && connection) {
|
|
433
467
|
inboxInstance = await startLegacyAgentInbox(connection);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scope-check.mjs — Generic PreToolUse hook for enforcing per-role MCP
|
|
4
|
+
* scope declared in a swarm-generated scope file.
|
|
5
|
+
*
|
|
6
|
+
* Invoked per sub-agent via frontmatter:
|
|
7
|
+
*
|
|
8
|
+
* hooks:
|
|
9
|
+
* PreToolUse:
|
|
10
|
+
* - matcher: "mcp__.*"
|
|
11
|
+
* hooks:
|
|
12
|
+
* - type: command
|
|
13
|
+
* command: ${CLAUDE_PLUGIN_ROOT}/scripts/scope-check.mjs
|
|
14
|
+
* env:
|
|
15
|
+
* SCOPE_FILE: .swarm/.../scope/<role>.json
|
|
16
|
+
* ROLE_NAME: <role>
|
|
17
|
+
*
|
|
18
|
+
* Input: tool-call JSON via stdin (Claude Code hook protocol).
|
|
19
|
+
* Output:
|
|
20
|
+
* exit 0 → allow
|
|
21
|
+
* exit 2 → block, with human-readable reason printed to stderr
|
|
22
|
+
*
|
|
23
|
+
* See docs/loadout-consumer-design.md for the larger design.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "fs";
|
|
27
|
+
|
|
28
|
+
const MCP_PREFIX = "mcp__";
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const input = await readStdin();
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = input ? JSON.parse(input) : {};
|
|
35
|
+
} catch {
|
|
36
|
+
// Can't parse input — allow, not our place to block on hook-protocol glitches.
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const toolName = parsed?.tool_name ?? "";
|
|
41
|
+
if (!toolName.startsWith(MCP_PREFIX)) {
|
|
42
|
+
// Shouldn't happen under the mcp__.* matcher, but be defensive.
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parts = toolName.slice(MCP_PREFIX.length).split("__");
|
|
47
|
+
if (parts.length < 2) {
|
|
48
|
+
// Malformed MCP tool name — allow, nothing to check.
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
const server = parts[0];
|
|
52
|
+
const tool = parts.slice(1).join("__");
|
|
53
|
+
|
|
54
|
+
const scopeFilePath = process.env.SCOPE_FILE;
|
|
55
|
+
if (!scopeFilePath) {
|
|
56
|
+
// No scope file configured — nothing to enforce, allow.
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let scopeDoc;
|
|
61
|
+
try {
|
|
62
|
+
scopeDoc = JSON.parse(fs.readFileSync(scopeFilePath, "utf-8"));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
writeError(
|
|
65
|
+
`scope-check: could not read scope file at ${scopeFilePath} ` +
|
|
66
|
+
`(${err.message}). Allowing tool call — install claude-code-swarm ` +
|
|
67
|
+
`correctly or remove the hook to silence this.`
|
|
68
|
+
);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const scopeList = Array.isArray(scopeDoc?.scope) ? scopeDoc.scope : [];
|
|
73
|
+
const entry = scopeList.find((s) => s?.server === server);
|
|
74
|
+
|
|
75
|
+
// If the server is referenced in scope but restricted → enforce.
|
|
76
|
+
// If the server is not in scope at all, Claude Code's own `mcpServers:`
|
|
77
|
+
// allowlist should have blocked this already; we don't second-guess it.
|
|
78
|
+
if (!entry) {
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const role = process.env.ROLE_NAME || scopeDoc?.role || "(unknown)";
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(entry.exclude) && entry.exclude.includes(tool)) {
|
|
85
|
+
writeError(
|
|
86
|
+
`Tool ${toolName} is denied for role "${role}" (listed in scope exclude).`
|
|
87
|
+
);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(entry.tools) && entry.tools.length > 0) {
|
|
92
|
+
if (!entry.tools.includes(tool)) {
|
|
93
|
+
writeError(
|
|
94
|
+
`Tool ${toolName} is not in the scope allowlist for role "${role}" ` +
|
|
95
|
+
`(allowed: ${entry.tools.join(", ")}).`
|
|
96
|
+
);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// No restriction hit — allow.
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeError(msg) {
|
|
106
|
+
process.stderr.write(msg + "\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readStdin() {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
// If stdin is a TTY we're running interactively — no input to consume.
|
|
112
|
+
if (process.stdin.isTTY) {
|
|
113
|
+
resolve("");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let buf = "";
|
|
117
|
+
process.stdin.setEncoding("utf-8");
|
|
118
|
+
process.stdin.on("data", (chunk) => {
|
|
119
|
+
buf += chunk;
|
|
120
|
+
});
|
|
121
|
+
process.stdin.on("end", () => resolve(buf));
|
|
122
|
+
process.stdin.on("error", reject);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main().catch((err) => {
|
|
127
|
+
// On any unexpected error, fail open (allow) with a message.
|
|
128
|
+
// Hook errors should not break the agent — blocking a valid tool
|
|
129
|
+
// call because our hook crashed is worse than allowing an out-of-scope one.
|
|
130
|
+
writeError(`scope-check: unexpected error — ${err?.message ?? err}`);
|
|
131
|
+
process.exit(0);
|
|
132
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: swarm-mcp
|
|
3
|
+
description: Inspect and optionally install MCP providers declared by the current swarm team. Non-invasive by default — reports status without modifying user MCP configuration unless explicitly requested.
|
|
4
|
+
user_invocable: true
|
|
5
|
+
argument: optional
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# /swarm-mcp — Inspect and Manage Team MCP Providers
|
|
9
|
+
|
|
10
|
+
You manage the **MCP (Model Context Protocol) surface** for the configured swarm team. Your role is to inspect declared providers, report health, and optionally install or sync with explicit user consent.
|
|
11
|
+
|
|
12
|
+
## Design principles
|
|
13
|
+
|
|
14
|
+
- **Non-invasive by default.** Do NOT modify `.mcp.json`, `.claude/settings.json`, `.claude/settings.local.json`, or user-global config without explicit user opt-in.
|
|
15
|
+
- **Check-and-notify over auto-install.** Surface discrepancies. Let the user decide.
|
|
16
|
+
- **Confirmation before any write.** Always show a diff preview and wait for explicit approval.
|
|
17
|
+
|
|
18
|
+
## Artifact sources
|
|
19
|
+
|
|
20
|
+
The swarm team loader (`scripts/team-loader.mjs`) has already populated the following cache files (one pass, at session start):
|
|
21
|
+
|
|
22
|
+
- `.swarm/claude-swarm/tmp/teams/<template>/mcp-providers.json` — team-declared provider install specs
|
|
23
|
+
- `.swarm/claude-swarm/tmp/teams/<template>/mcp-health.json` — pre-computed health report (regenerated each session)
|
|
24
|
+
- `.swarm/claude-swarm/tmp/teams/<template>/scope/<role>.json` — per-role scope files consumed by the PreToolUse hook
|
|
25
|
+
- `.swarm/claude-swarm/tmp/teams/<template>/loadouts/<role>.json` — debug view of resolved per-role loadout
|
|
26
|
+
|
|
27
|
+
Do NOT regenerate these files. They're owned by the team-loader.
|
|
28
|
+
|
|
29
|
+
## Subcommands
|
|
30
|
+
|
|
31
|
+
Parse `$ARGUMENTS` as the first token to determine which subcommand to run.
|
|
32
|
+
|
|
33
|
+
### `check` — Dry-run health report
|
|
34
|
+
|
|
35
|
+
Purpose: show declared providers vs active set, flag missing / refs / disabled / orphaned scope references.
|
|
36
|
+
|
|
37
|
+
Steps:
|
|
38
|
+
1. Read `.swarm/claude-swarm/tmp/teams/<template>/mcp-health.json`. If missing, ask the user to reload the team (`/swarm <template>`) — the loader populates this file.
|
|
39
|
+
2. Render the report using the classification fields:
|
|
40
|
+
- `ok[]` — servers declared AND active (checkmark + source)
|
|
41
|
+
- `missing[]` — declared, not active (warning + suggest `install`)
|
|
42
|
+
- `refs[]` — symbolic refs deferred to consumer (inform)
|
|
43
|
+
- `disabled[]` — declared with `disabled: true`
|
|
44
|
+
- `activeOnly[]` — active but not declared (informational)
|
|
45
|
+
- `orphanedReferences[]` — loadout scope references not backed by anything
|
|
46
|
+
3. Print a compact table. Do NOT write any files.
|
|
47
|
+
|
|
48
|
+
### `install [name]` — Explicit MCP install
|
|
49
|
+
|
|
50
|
+
Purpose: append a team-declared provider (from `mcp-providers.json`) to the project `.mcp.json` after explicit user confirmation. If `[name]` is omitted, list installable entries and ask the user to pick.
|
|
51
|
+
|
|
52
|
+
Steps:
|
|
53
|
+
1. Read `.swarm/claude-swarm/tmp/teams/<template>/mcp-providers.json`.
|
|
54
|
+
2. Read (or initialize) project-root `.mcp.json`.
|
|
55
|
+
3. For each requested `name`:
|
|
56
|
+
a. Strip openteams-specific fields (`ref`, `description`, `disabled`) from the provider spec.
|
|
57
|
+
b. If `.mcp.json` already contains the same name, diff the specs and show both to the user. Ask whether to overwrite.
|
|
58
|
+
c. Show the full diff that would land in `.mcp.json`.
|
|
59
|
+
d. Wait for explicit user approval (`yes` / `install` / `y`) before writing.
|
|
60
|
+
4. Write `.mcp.json` atomically. Inform the user they will need to restart Claude Code for the new server to connect.
|
|
61
|
+
|
|
62
|
+
Refuse to proceed if:
|
|
63
|
+
- The provider has `disabled: true` (skip with a message)
|
|
64
|
+
- The provider has `ref:` but no bundled registry to resolve it (skip with a suggestion to run `/swarm-mcp resolve-ref`)
|
|
65
|
+
- The user hasn't confirmed
|
|
66
|
+
|
|
67
|
+
### `permissions-sync` — Explicit permissions sync
|
|
68
|
+
|
|
69
|
+
Purpose: merge loadout-driven permissions into `.claude/settings.local.json` under a clearly-marked swarm block. `settings.local.json` is user-owned and typically gitignored, so we never touch `.claude/settings.json`.
|
|
70
|
+
|
|
71
|
+
Steps:
|
|
72
|
+
1. Collect the union of all roles' permissions:
|
|
73
|
+
- `.swarm/claude-swarm/tmp/teams/<template>/scope/<role>.json` → `permissions.{allow,deny,ask}`
|
|
74
|
+
- Scope entries → translated to `mcp__<server>__*` (for bare server access) and `mcp__<server>__<tool>` (for exclude entries)
|
|
75
|
+
2. Read `.claude/settings.local.json` (or initialize an empty object if absent).
|
|
76
|
+
3. Locate or create a marked block:
|
|
77
|
+
```json
|
|
78
|
+
"permissions": {
|
|
79
|
+
"allow": [
|
|
80
|
+
"/* swarm:<template>:start */",
|
|
81
|
+
"...",
|
|
82
|
+
"/* swarm:<template>:end */"
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
(If JSON doesn't tolerate comments, use a sentinel entry like `"// swarm:<template>:start"`.)
|
|
87
|
+
4. Replace the marked block's contents; preserve everything outside the sentinel fences.
|
|
88
|
+
5. Show the diff, wait for confirmation, write atomically.
|
|
89
|
+
|
|
90
|
+
### `clean` — Remove swarm-generated agents
|
|
91
|
+
|
|
92
|
+
Purpose: remove `.claude/agents/<team>-*` files written by swarm, respecting the `generated_by: claude-code-swarm` marker. Never touches hand-authored agents.
|
|
93
|
+
|
|
94
|
+
Steps:
|
|
95
|
+
1. Scan `.claude/agents/*.md` (or `~/.claude/agents/<project-slug>-<team>-*.md` if the current team uses user scope).
|
|
96
|
+
2. Filter to files whose frontmatter contains:
|
|
97
|
+
- `generated_by: claude-code-swarm` AND
|
|
98
|
+
- `team_name: <current-template>` (if `--team` given) or any swarm team (default)
|
|
99
|
+
3. Show the list and confirm with the user before deleting.
|
|
100
|
+
4. Optionally also delete `.swarm/claude-swarm/tmp/teams/<template>/` cache files when `--with-artifacts` is given.
|
|
101
|
+
|
|
102
|
+
### `resolve-ref <ref>` — (Deferred)
|
|
103
|
+
|
|
104
|
+
For future use. Currently prints a stub message indicating ref resolution requires an OpenHive hive connection or a bundled registry (not yet shipped).
|
|
105
|
+
|
|
106
|
+
## Default behavior when no subcommand given
|
|
107
|
+
|
|
108
|
+
If `$ARGUMENTS` is empty, run the `check` subcommand — the safest and most informative default.
|
|
109
|
+
|
|
110
|
+
## What you must NOT do
|
|
111
|
+
|
|
112
|
+
- Do not modify `.mcp.json` without explicit user confirmation
|
|
113
|
+
- Do not modify `.claude/settings.json` (committed project settings) — only `.claude/settings.local.json`
|
|
114
|
+
- Do not touch user-global config in `~/.claude/` without explicit opt-in
|
|
115
|
+
- Do not invoke `install` or `permissions-sync` without a clear user yes
|
|
116
|
+
- Do not re-run the team-loader from inside this skill (`/swarm` or `loadTeam` does that)
|