claude-code-swarm 0.3.24 → 0.3.26

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 (34) 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-hook.mjs +30 -5
  7. package/scripts/map-sidecar.mjs +32 -0
  8. package/scripts/scope-check.mjs +132 -0
  9. package/skills/swarm-mcp/SKILL.md +116 -0
  10. package/src/__tests__/cognitive-core-loadout-e2e.test.mjs +260 -0
  11. package/src/__tests__/e2e-loadout-demo.test.mjs +150 -0
  12. package/src/__tests__/fixtures/loadout-compile-team/loadouts/base-reviewer.yaml +16 -0
  13. package/src/__tests__/fixtures/loadout-compile-team/loadouts/extended-security.yaml +10 -0
  14. package/src/__tests__/fixtures/loadout-compile-team/roles/auditor.yaml +4 -0
  15. package/src/__tests__/fixtures/loadout-compile-team/roles/inline-extender.yaml +10 -0
  16. package/src/__tests__/fixtures/loadout-compile-team/roles/reviewer.yaml +4 -0
  17. package/src/__tests__/fixtures/loadout-compile-team/team.yaml +15 -0
  18. package/src/__tests__/loadout-materializer.test.mjs +578 -0
  19. package/src/__tests__/loadout-schema-bridge.test.mjs +176 -0
  20. package/src/__tests__/loadout-skilltree-compile-e2e.test.mjs +444 -0
  21. package/src/__tests__/loadout-template-shape.test.mjs +102 -0
  22. package/src/__tests__/mcp-health-checker.test.mjs +327 -0
  23. package/src/__tests__/scope-check.test.mjs +210 -0
  24. package/src/__tests__/sidecar-nudge.test.mjs +137 -0
  25. package/src/__tests__/skilltree-client.test.mjs +185 -1
  26. package/src/agent-generator.mjs +135 -8
  27. package/src/bootstrap.mjs +17 -9
  28. package/src/context-output.mjs +32 -0
  29. package/src/loadout-materializer.mjs +315 -0
  30. package/src/map-events.mjs +8 -1
  31. package/src/mcp-health-checker.mjs +237 -0
  32. package/src/sidecar-server.mjs +36 -0
  33. package/src/skilltree-client.mjs +135 -24
  34. package/src/template.mjs +158 -2
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Loadout template shape — runtime-shape verification.
3
+ *
4
+ * Loads the openteams `loadout-demo` template via `TemplateLoader.load()`
5
+ * and verifies that `template.roles.<name>.loadout.skills.*` keeps the
6
+ * snake_case field names declared in the JSON schema. The bridge
7
+ * (`mergeOpenteamsSkillsIntoCriteria`) reads `max_tokens` — if the loader
8
+ * normalizes to camelCase server-side, the bridge would produce wrong
9
+ * output silently.
10
+ *
11
+ * This test is the ground-truth check that the schema field names match
12
+ * the runtime shape consumed by the bridge.
13
+ */
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import { fileURLToPath } from "url";
18
+ import { describe, it, expect } from "vitest";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const LOADOUT_DEMO = path.resolve(
22
+ __dirname,
23
+ "../../../openteams/examples/loadout-demo",
24
+ );
25
+
26
+ function openteamsAvailable() {
27
+ try {
28
+ const cwd = path.resolve(__dirname, "../..");
29
+ require.resolve("openteams", { paths: [cwd] });
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ const SKIP = !fs.existsSync(LOADOUT_DEMO) || !openteamsAvailable();
37
+
38
+ describe.skipIf(SKIP)("loadout template shape (openteams runtime)", () => {
39
+ /** @type {import('openteams')} */
40
+ let ot;
41
+ /** @type {import('openteams').ResolvedTemplate} */
42
+ let template;
43
+
44
+ it("loads loadout-demo via openteams TemplateLoader", async () => {
45
+ ot = await import("openteams");
46
+ expect(ot.TemplateLoader).toBeDefined();
47
+ template = ot.TemplateLoader.load(LOADOUT_DEMO);
48
+ expect(template).toBeDefined();
49
+ expect(template.roles).toBeDefined();
50
+ });
51
+
52
+ it("template.roles is a Map keyed by role name", () => {
53
+ // Bridge code calls .get() — fail loudly if the runtime shape is plain object.
54
+ expect(template.roles instanceof Map).toBe(true);
55
+ expect(template.roles.has("reviewer")).toBe(true);
56
+ expect(template.roles.has("implementer")).toBe(true);
57
+ });
58
+
59
+ it("reviewer role has a loadout with skills (extends chain resolved)", () => {
60
+ const role = template.roles.get("reviewer");
61
+ expect(role?.loadout).toBeDefined();
62
+ expect(role.loadout.skills).toBeDefined();
63
+ });
64
+
65
+ it("loadout.skills retains snake_case field name max_tokens (NOT maxTokens)", () => {
66
+ // This is the load-bearing assertion. The bridge in
67
+ // skilltree-client.mjs reads `loadoutSkills.max_tokens`. If the
68
+ // loader normalized to camelCase, the bridge would never see the
69
+ // value and silently produce LoadoutCriteria with no maxTokens.
70
+ const reviewer = template.roles.get("reviewer");
71
+ const skills = reviewer.loadout.skills;
72
+
73
+ expect(Object.prototype.hasOwnProperty.call(skills, "max_tokens")).toBe(true);
74
+ expect(Object.prototype.hasOwnProperty.call(skills, "maxTokens")).toBe(false);
75
+ expect(typeof skills.max_tokens).toBe("number");
76
+ });
77
+
78
+ it("reviewer's max_tokens is inherited from code-reviewer parent (30000)", () => {
79
+ // Sanity check that `extends:` resolution flowed through; otherwise
80
+ // the field-name test above could be a false positive on a
81
+ // self-declared value.
82
+ const skills = template.roles.get("reviewer").loadout.skills;
83
+ expect(skills.max_tokens).toBe(30000);
84
+ });
85
+
86
+ it("loadout.skills.profile is camelCase-free string (no normalization)", () => {
87
+ // Profile is single-word so it can't visibly normalize, but assert
88
+ // the key is `profile` (not `Profile` or similar) and the value is
89
+ // a string, just to lock the shape.
90
+ const skills = template.roles.get("reviewer").loadout.skills;
91
+ expect(typeof skills.profile).toBe("string");
92
+ expect(skills.profile.length).toBeGreaterThan(0);
93
+ });
94
+
95
+ it("loadout.skills.include is an array of skill ids", () => {
96
+ const skills = template.roles.get("reviewer").loadout.skills;
97
+ expect(Array.isArray(skills.include)).toBe(true);
98
+ // reviewer extends security-auditor (extends code-reviewer);
99
+ // include is unioned across the chain.
100
+ expect(skills.include.length).toBeGreaterThan(0);
101
+ });
102
+ });
@@ -0,0 +1,327 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import {
6
+ checkMcpHealth,
7
+ collectScopeReferences,
8
+ discoverActiveSet,
9
+ formatHealthReport,
10
+ } from "../mcp-health-checker.mjs";
11
+
12
+ describe("checkMcpHealth", () => {
13
+ it("classifies declared servers as ok when active", () => {
14
+ const report = checkMcpHealth({
15
+ providers: new Map([
16
+ ["ast-grep", { command: "npx", args: ["ast-grep-mcp"] }],
17
+ ]),
18
+ activeSet: new Map([
19
+ ["ast-grep", { source: "project", spec: { command: "npx" } }],
20
+ ]),
21
+ });
22
+
23
+ expect(report.ok).toEqual([
24
+ expect.objectContaining({ name: "ast-grep", source: "project" }),
25
+ ]);
26
+ expect(report.missing).toEqual([]);
27
+ });
28
+
29
+ it("classifies declared-but-not-active servers as missing", () => {
30
+ const report = checkMcpHealth({
31
+ providers: {
32
+ "chrome-devtools": { command: "npx", args: ["chrome-devtools-mcp"] },
33
+ },
34
+ activeSet: new Map(),
35
+ });
36
+
37
+ expect(report.missing).toEqual([
38
+ expect.objectContaining({ name: "chrome-devtools" }),
39
+ ]);
40
+ expect(report.ok).toEqual([]);
41
+ });
42
+
43
+ it("separates ref providers from concrete providers", () => {
44
+ const report = checkMcpHealth({
45
+ providers: {
46
+ "secrets-scanner": { ref: "@openhive/secrets-scanner" },
47
+ "ast-grep": { command: "npx" },
48
+ },
49
+ activeSet: new Map([
50
+ ["ast-grep", { source: "user" }],
51
+ ]),
52
+ });
53
+
54
+ expect(report.refs).toEqual([
55
+ expect.objectContaining({
56
+ name: "secrets-scanner",
57
+ ref: "@openhive/secrets-scanner",
58
+ }),
59
+ ]);
60
+ expect(report.ok).toEqual([
61
+ expect.objectContaining({ name: "ast-grep", source: "user" }),
62
+ ]);
63
+ });
64
+
65
+ it("treats disabled providers as a distinct category", () => {
66
+ const report = checkMcpHealth({
67
+ providers: {
68
+ "ast-grep": { command: "npx", disabled: true },
69
+ },
70
+ activeSet: new Map(),
71
+ });
72
+
73
+ expect(report.disabled).toEqual([
74
+ expect.objectContaining({ name: "ast-grep" }),
75
+ ]);
76
+ expect(report.missing).toEqual([]);
77
+ });
78
+
79
+ it("surfaces active servers not declared as activeOnly", () => {
80
+ const report = checkMcpHealth({
81
+ providers: new Map(),
82
+ activeSet: new Map([
83
+ ["opentasks", { source: "plugin" }],
84
+ ["agent-inbox", { source: "plugin" }],
85
+ ]),
86
+ });
87
+
88
+ expect(report.activeOnly.map((a) => a.name).sort()).toEqual([
89
+ "agent-inbox",
90
+ "opentasks",
91
+ ]);
92
+ });
93
+
94
+ it("flags scope references to servers not in providers or active", () => {
95
+ const report = checkMcpHealth({
96
+ providers: {
97
+ "ast-grep": { command: "npx" },
98
+ },
99
+ activeSet: new Map([["ast-grep", { source: "project" }]]),
100
+ scopeReferences: [
101
+ { loadout: "code-reviewer", server: "ast-grep" }, // backed
102
+ { loadout: "debug-flow", server: "missing-thing" }, // orphan
103
+ ],
104
+ });
105
+
106
+ expect(report.orphanedReferences).toEqual([
107
+ { loadout: "debug-flow", server: "missing-thing" },
108
+ ]);
109
+ });
110
+
111
+ it("accepts both Map and plain-object inputs", () => {
112
+ const r1 = checkMcpHealth({
113
+ providers: { x: { command: "y" } },
114
+ activeSet: { x: { source: "user" } },
115
+ });
116
+ const r2 = checkMcpHealth({
117
+ providers: new Map([["x", { command: "y" }]]),
118
+ activeSet: new Map([["x", { source: "user" }]]),
119
+ });
120
+
121
+ expect(r1.ok).toHaveLength(1);
122
+ expect(r2.ok).toHaveLength(1);
123
+ });
124
+ });
125
+
126
+ describe("collectScopeReferences", () => {
127
+ it("extracts scope references from a loadouts map", () => {
128
+ const loadouts = new Map([
129
+ [
130
+ "reviewer",
131
+ {
132
+ mcpScope: [
133
+ { server: "ast-grep" },
134
+ { server: "chrome-devtools", tools: ["navigate"] },
135
+ ],
136
+ },
137
+ ],
138
+ ["planner", { mcpScope: [] }],
139
+ ["implementer", { mcpScope: [{ server: "filesystem" }] }],
140
+ ]);
141
+
142
+ const refs = collectScopeReferences(loadouts);
143
+ expect(refs).toEqual([
144
+ { loadout: "reviewer", server: "ast-grep" },
145
+ { loadout: "reviewer", server: "chrome-devtools" },
146
+ { loadout: "implementer", server: "filesystem" },
147
+ ]);
148
+ });
149
+
150
+ it("accepts plain-object loadouts", () => {
151
+ const refs = collectScopeReferences({
152
+ x: { mcpScope: [{ server: "a" }] },
153
+ });
154
+ expect(refs).toEqual([{ loadout: "x", server: "a" }]);
155
+ });
156
+
157
+ it("returns empty array for empty input", () => {
158
+ expect(collectScopeReferences()).toEqual([]);
159
+ expect(collectScopeReferences(new Map())).toEqual([]);
160
+ });
161
+ });
162
+
163
+ describe("discoverActiveSet", () => {
164
+ let tmpProject;
165
+ let tmpHome;
166
+ let tmpPlugin;
167
+
168
+ beforeEach(() => {
169
+ tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), "swarm-proj-"));
170
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "swarm-home-"));
171
+ tmpPlugin = fs.mkdtempSync(path.join(os.tmpdir(), "swarm-plugin-"));
172
+ });
173
+
174
+ afterEach(() => {
175
+ fs.rmSync(tmpProject, { recursive: true, force: true });
176
+ fs.rmSync(tmpHome, { recursive: true, force: true });
177
+ fs.rmSync(tmpPlugin, { recursive: true, force: true });
178
+ });
179
+
180
+ function writeJson(p, obj) {
181
+ fs.mkdirSync(path.dirname(p), { recursive: true });
182
+ fs.writeFileSync(p, JSON.stringify(obj));
183
+ }
184
+
185
+ it("returns empty map when no sources exist", () => {
186
+ const result = discoverActiveSet({
187
+ projectPath: tmpProject,
188
+ pluginPath: tmpPlugin,
189
+ userHome: tmpHome,
190
+ });
191
+ expect(result.size).toBe(0);
192
+ });
193
+
194
+ it("reads plugin.json mcpServers with source=plugin", () => {
195
+ writeJson(path.join(tmpPlugin, ".claude-plugin", "plugin.json"), {
196
+ mcpServers: { opentasks: { command: "node" } },
197
+ });
198
+
199
+ const result = discoverActiveSet({
200
+ projectPath: tmpProject,
201
+ pluginPath: tmpPlugin,
202
+ userHome: tmpHome,
203
+ });
204
+
205
+ expect(result.get("opentasks")).toEqual(
206
+ expect.objectContaining({ source: "plugin" })
207
+ );
208
+ });
209
+
210
+ it("reads project .mcp.json with source=project", () => {
211
+ writeJson(path.join(tmpProject, ".mcp.json"), {
212
+ mcpServers: { "ast-grep": { command: "npx" } },
213
+ });
214
+
215
+ const result = discoverActiveSet({
216
+ projectPath: tmpProject,
217
+ pluginPath: tmpPlugin,
218
+ userHome: tmpHome,
219
+ });
220
+
221
+ expect(result.get("ast-grep")).toEqual(
222
+ expect.objectContaining({ source: "project" })
223
+ );
224
+ });
225
+
226
+ it("reads ~/.claude/mcp.json with source=user", () => {
227
+ writeJson(path.join(tmpHome, ".claude", "mcp.json"), {
228
+ mcpServers: { "my-mcp": { command: "node" } },
229
+ });
230
+
231
+ const result = discoverActiveSet({
232
+ projectPath: tmpProject,
233
+ pluginPath: tmpPlugin,
234
+ userHome: tmpHome,
235
+ });
236
+
237
+ expect(result.get("my-mcp")).toEqual(
238
+ expect.objectContaining({ source: "user" })
239
+ );
240
+ });
241
+
242
+ it("project overrides user overrides plugin on name conflict", () => {
243
+ writeJson(path.join(tmpPlugin, ".claude-plugin", "plugin.json"), {
244
+ mcpServers: { fs: { command: "plugin-fs" } },
245
+ });
246
+ writeJson(path.join(tmpHome, ".claude", "mcp.json"), {
247
+ mcpServers: { fs: { command: "user-fs" } },
248
+ });
249
+ writeJson(path.join(tmpProject, ".mcp.json"), {
250
+ mcpServers: { fs: { command: "project-fs" } },
251
+ });
252
+
253
+ const result = discoverActiveSet({
254
+ projectPath: tmpProject,
255
+ pluginPath: tmpPlugin,
256
+ userHome: tmpHome,
257
+ });
258
+
259
+ expect(result.get("fs")).toEqual(
260
+ expect.objectContaining({
261
+ source: "project",
262
+ spec: { command: "project-fs" },
263
+ })
264
+ );
265
+ });
266
+
267
+ it("silently skips malformed JSON", () => {
268
+ const mcpPath = path.join(tmpProject, ".mcp.json");
269
+ fs.writeFileSync(mcpPath, "{not valid json]");
270
+
271
+ const result = discoverActiveSet({
272
+ projectPath: tmpProject,
273
+ pluginPath: tmpPlugin,
274
+ userHome: tmpHome,
275
+ });
276
+
277
+ expect(result.size).toBe(0);
278
+ });
279
+ });
280
+
281
+ describe("formatHealthReport", () => {
282
+ it("renders a header with the team name", () => {
283
+ const report = checkMcpHealth({
284
+ providers: new Map([["x", { command: "y" }]]),
285
+ activeSet: new Map(),
286
+ });
287
+ const out = formatHealthReport(report, { teamName: "demo" });
288
+ expect(out).toContain('Team "demo" MCP status');
289
+ });
290
+
291
+ it("marks ok entries with a checkmark and source", () => {
292
+ const report = checkMcpHealth({
293
+ providers: { "ast-grep": { command: "npx" } },
294
+ activeSet: new Map([["ast-grep", { source: "project" }]]),
295
+ });
296
+ const out = formatHealthReport(report);
297
+ expect(out).toContain("✓ ast-grep");
298
+ expect(out).toContain("(project)");
299
+ });
300
+
301
+ it("marks missing with warning and an actionable hint", () => {
302
+ const report = checkMcpHealth({
303
+ providers: { "chrome-devtools": { command: "npx" } },
304
+ activeSet: new Map(),
305
+ });
306
+ const out = formatHealthReport(report);
307
+ expect(out).toContain("⚠ chrome-devtools");
308
+ expect(out).toContain("/swarm mcp install chrome-devtools");
309
+ });
310
+
311
+ it("surfaces orphaned scope references in a dedicated section", () => {
312
+ const report = checkMcpHealth({
313
+ providers: new Map(),
314
+ activeSet: new Map(),
315
+ scopeReferences: [{ loadout: "x", server: "missing" }],
316
+ });
317
+ const out = formatHealthReport(report);
318
+ expect(out).toContain("not backed by any provider");
319
+ expect(out).toContain('loadout "x" uses "missing"');
320
+ });
321
+
322
+ it("falls back to a neutral message when nothing is declared", () => {
323
+ const report = checkMcpHealth({ providers: new Map(), activeSet: new Map() });
324
+ const out = formatHealthReport(report);
325
+ expect(out).toContain("no MCP providers declared");
326
+ });
327
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Integration tests for the scope-check hook.
3
+ * Runs the hook as a subprocess, pipes stdin JSON, checks exit code + stderr.
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { spawnSync } from "child_process";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+
11
+ const HOOK = path.resolve(
12
+ new URL("..", import.meta.url).pathname,
13
+ "..",
14
+ "scripts",
15
+ "scope-check.mjs"
16
+ );
17
+
18
+ function runHook({ toolName, scopeFile, roleName } = {}) {
19
+ const env = { ...process.env };
20
+ if (scopeFile) env.SCOPE_FILE = scopeFile;
21
+ if (roleName) env.ROLE_NAME = roleName;
22
+
23
+ const stdin = JSON.stringify({
24
+ tool_name: toolName,
25
+ tool_input: {},
26
+ });
27
+
28
+ const result = spawnSync("node", [HOOK], {
29
+ input: stdin,
30
+ env,
31
+ encoding: "utf-8",
32
+ timeout: 5000,
33
+ });
34
+ return {
35
+ exitCode: result.status,
36
+ stderr: result.stderr,
37
+ stdout: result.stdout,
38
+ };
39
+ }
40
+
41
+ describe("scope-check hook", () => {
42
+ let tmpDir;
43
+ let scopeFile;
44
+
45
+ beforeEach(() => {
46
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "swarm-scope-"));
47
+ scopeFile = path.join(tmpDir, "scope.json");
48
+ });
49
+
50
+ afterEach(() => {
51
+ fs.rmSync(tmpDir, { recursive: true, force: true });
52
+ });
53
+
54
+ function writeScope(doc) {
55
+ fs.writeFileSync(scopeFile, JSON.stringify(doc));
56
+ }
57
+
58
+ // ─── Allow cases ───
59
+
60
+ it("allows non-MCP tool calls (defensive)", () => {
61
+ writeScope({ role: "r", scope: [] });
62
+ const { exitCode } = runHook({
63
+ toolName: "Read",
64
+ scopeFile,
65
+ roleName: "reviewer",
66
+ });
67
+ expect(exitCode).toBe(0);
68
+ });
69
+
70
+ it("allows when no SCOPE_FILE is set", () => {
71
+ const { exitCode } = runHook({ toolName: "mcp__ast-grep__search" });
72
+ expect(exitCode).toBe(0);
73
+ });
74
+
75
+ it("allows when scope file is missing (fail-open)", () => {
76
+ const { exitCode, stderr } = runHook({
77
+ toolName: "mcp__ast-grep__search",
78
+ scopeFile: path.join(tmpDir, "does-not-exist.json"),
79
+ });
80
+ expect(exitCode).toBe(0);
81
+ expect(stderr).toContain("could not read scope file");
82
+ });
83
+
84
+ it("allows when the server is not in scope (Claude Code's allowlist gates elsewhere)", () => {
85
+ writeScope({
86
+ role: "r",
87
+ scope: [{ server: "opentasks" }],
88
+ });
89
+ const { exitCode } = runHook({
90
+ toolName: "mcp__chrome-devtools__navigate",
91
+ scopeFile,
92
+ });
93
+ expect(exitCode).toBe(0);
94
+ });
95
+
96
+ it("allows a tool within an allowlist", () => {
97
+ writeScope({
98
+ role: "r",
99
+ scope: [{ server: "chrome-devtools", tools: ["navigate", "screenshot"] }],
100
+ });
101
+ const { exitCode } = runHook({
102
+ toolName: "mcp__chrome-devtools__navigate",
103
+ scopeFile,
104
+ roleName: "reviewer",
105
+ });
106
+ expect(exitCode).toBe(0);
107
+ });
108
+
109
+ it("allows a tool when server is in scope with no restrictions", () => {
110
+ writeScope({
111
+ role: "r",
112
+ scope: [{ server: "ast-grep" }],
113
+ });
114
+ const { exitCode } = runHook({
115
+ toolName: "mcp__ast-grep__search",
116
+ scopeFile,
117
+ });
118
+ expect(exitCode).toBe(0);
119
+ });
120
+
121
+ // ─── Deny cases ───
122
+
123
+ it("denies a tool outside the allowlist", () => {
124
+ writeScope({
125
+ role: "r",
126
+ scope: [{ server: "chrome-devtools", tools: ["navigate"] }],
127
+ });
128
+ const { exitCode, stderr } = runHook({
129
+ toolName: "mcp__chrome-devtools__evaluate_script",
130
+ scopeFile,
131
+ roleName: "reviewer",
132
+ });
133
+ expect(exitCode).toBe(2);
134
+ expect(stderr).toContain("not in the scope allowlist");
135
+ expect(stderr).toContain("reviewer");
136
+ });
137
+
138
+ it("denies a tool in an exclude list", () => {
139
+ writeScope({
140
+ role: "r",
141
+ scope: [{ server: "ast-grep", exclude: ["dangerous_replace"] }],
142
+ });
143
+ const { exitCode, stderr } = runHook({
144
+ toolName: "mcp__ast-grep__dangerous_replace",
145
+ scopeFile,
146
+ roleName: "reviewer",
147
+ });
148
+ expect(exitCode).toBe(2);
149
+ expect(stderr).toContain("scope exclude");
150
+ });
151
+
152
+ it("exclude is checked before allowlist", () => {
153
+ writeScope({
154
+ role: "r",
155
+ scope: [
156
+ {
157
+ server: "ast-grep",
158
+ tools: ["search", "dangerous_replace"], // mistakenly allows
159
+ exclude: ["dangerous_replace"], // but also excludes
160
+ },
161
+ ],
162
+ });
163
+ const { exitCode, stderr } = runHook({
164
+ toolName: "mcp__ast-grep__dangerous_replace",
165
+ scopeFile,
166
+ roleName: "reviewer",
167
+ });
168
+ expect(exitCode).toBe(2);
169
+ expect(stderr).toContain("exclude");
170
+ });
171
+
172
+ // ─── Edge cases ───
173
+
174
+ it("handles malformed stdin as allow (fail-open on protocol glitches)", () => {
175
+ writeScope({ role: "r", scope: [] });
176
+ const result = spawnSync("node", [HOOK], {
177
+ input: "{not json]",
178
+ env: { ...process.env, SCOPE_FILE: scopeFile },
179
+ encoding: "utf-8",
180
+ timeout: 5000,
181
+ });
182
+ expect(result.status).toBe(0);
183
+ });
184
+
185
+ it("handles tool names with multiple underscores correctly", () => {
186
+ // mcp__server__tool_with_underscores → server=server, tool=tool_with_underscores
187
+ writeScope({
188
+ role: "r",
189
+ scope: [{ server: "my-server", tools: ["tool_with_underscores"] }],
190
+ });
191
+ const { exitCode } = runHook({
192
+ toolName: "mcp__my-server__tool_with_underscores",
193
+ scopeFile,
194
+ });
195
+ expect(exitCode).toBe(0);
196
+ });
197
+
198
+ it("derives role from scope file when ROLE_NAME env missing", () => {
199
+ writeScope({
200
+ role: "fallback-role",
201
+ scope: [{ server: "x", tools: ["a"] }],
202
+ });
203
+ const { stderr } = runHook({
204
+ toolName: "mcp__x__b",
205
+ scopeFile,
206
+ // no roleName
207
+ });
208
+ expect(stderr).toContain("fallback-role");
209
+ });
210
+ });