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.
- 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-hook.mjs +30 -5
- package/scripts/map-sidecar.mjs +32 -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 +176 -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__/sidecar-nudge.test.mjs +137 -0
- package/src/__tests__/skilltree-client.test.mjs +185 -1
- package/src/agent-generator.mjs +135 -8
- package/src/bootstrap.mjs +17 -9
- package/src/context-output.mjs +32 -0
- package/src/loadout-materializer.mjs +315 -0
- package/src/map-events.mjs +8 -1
- package/src/mcp-health-checker.mjs +237 -0
- package/src/sidecar-server.mjs +36 -0
- package/src/skilltree-client.mjs +135 -24
- package/src/template.mjs +158 -2
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildFrontmatter,
|
|
4
|
+
buildScopeFile,
|
|
5
|
+
materializeLoadout,
|
|
6
|
+
resolveMcpScope,
|
|
7
|
+
scopeNeedsHook,
|
|
8
|
+
} from "../loadout-materializer.mjs";
|
|
9
|
+
|
|
10
|
+
// ────────────────────────────────────────────────────────────
|
|
11
|
+
// Fixtures
|
|
12
|
+
// ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function makeRole(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
name: "reviewer",
|
|
17
|
+
description: "Reviews code and proposes improvements",
|
|
18
|
+
displayName: "Reviewer",
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeLoadout(overrides = {}) {
|
|
24
|
+
return {
|
|
25
|
+
name: "reviewer-loadout",
|
|
26
|
+
description: "Reviewer loadout",
|
|
27
|
+
skills: { profile: "code-reviewer" },
|
|
28
|
+
capabilities: ["file.read", "git.diff"],
|
|
29
|
+
capabilityConfig: undefined,
|
|
30
|
+
mcpServers: [],
|
|
31
|
+
mcpScope: [],
|
|
32
|
+
permissions: {},
|
|
33
|
+
promptAddendum: undefined,
|
|
34
|
+
raw: {},
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeTemplate(overrides = {}) {
|
|
40
|
+
return {
|
|
41
|
+
manifest: { name: "loadout-demo", version: 1, roles: ["reviewer"], topology: { root: { role: "reviewer" } } },
|
|
42
|
+
mcpProviders: new Map(),
|
|
43
|
+
roles: new Map(),
|
|
44
|
+
loadouts: new Map(),
|
|
45
|
+
prompts: new Map(),
|
|
46
|
+
mcpServers: new Map(),
|
|
47
|
+
sourcePath: "",
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function commonOptions(overrides = {}) {
|
|
53
|
+
return {
|
|
54
|
+
teamName: "loadout-demo",
|
|
55
|
+
projectPath: "/abs/project",
|
|
56
|
+
scopeFilePath: ".swarm/claude-swarm/tmp/teams/loadout-demo/scope/reviewer.json",
|
|
57
|
+
hookCommand: "${CLAUDE_PLUGIN_ROOT}/hooks/scope-check.mjs",
|
|
58
|
+
nativeTools: ["Read", "Grep", "Glob", "Bash"],
|
|
59
|
+
position: "companion",
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ────────────────────────────────────────────────────────────
|
|
65
|
+
// Input validation
|
|
66
|
+
// ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("materializeLoadout — input validation", () => {
|
|
69
|
+
it("throws when role.name is missing", () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
materializeLoadout({
|
|
72
|
+
role: {},
|
|
73
|
+
loadout: makeLoadout(),
|
|
74
|
+
template: makeTemplate(),
|
|
75
|
+
})
|
|
76
|
+
).toThrow(/role\.name is required/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ────────────────────────────────────────────────────────────
|
|
81
|
+
// No-loadout path
|
|
82
|
+
// ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe("materializeLoadout — role with no loadout", () => {
|
|
85
|
+
it("produces frontmatter without mcp-related fields", () => {
|
|
86
|
+
const { frontmatter, scopeFile, warnings } = materializeLoadout({
|
|
87
|
+
role: makeRole(),
|
|
88
|
+
loadout: undefined,
|
|
89
|
+
template: makeTemplate(),
|
|
90
|
+
options: commonOptions(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(frontmatter.name).toBe("loadout-demo-reviewer");
|
|
94
|
+
expect(frontmatter.team_name).toBe("loadout-demo");
|
|
95
|
+
expect(frontmatter.role).toBe("reviewer");
|
|
96
|
+
expect(frontmatter.generated_by).toBe("claude-code-swarm");
|
|
97
|
+
expect(typeof frontmatter.generated_at).toBe("string");
|
|
98
|
+
expect(frontmatter.tools).toEqual(["Read", "Grep", "Glob", "Bash"]);
|
|
99
|
+
|
|
100
|
+
expect(frontmatter.mcpServers).toBeUndefined();
|
|
101
|
+
expect(frontmatter.disallowedTools).toBeUndefined();
|
|
102
|
+
expect(frontmatter.hooks).toBeUndefined();
|
|
103
|
+
expect(frontmatter.capabilities).toBeUndefined();
|
|
104
|
+
|
|
105
|
+
expect(scopeFile.scope).toEqual([]);
|
|
106
|
+
expect(scopeFile.permissions).toEqual({ allow: [], deny: [], ask: [] });
|
|
107
|
+
expect(warnings).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ────────────────────────────────────────────────────────────
|
|
112
|
+
// Scope-only entries
|
|
113
|
+
// ────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("materializeLoadout — scope-only entries", () => {
|
|
116
|
+
it("emits mcpServers as string refs for bare scope entries", () => {
|
|
117
|
+
const { frontmatter } = materializeLoadout({
|
|
118
|
+
role: makeRole(),
|
|
119
|
+
loadout: makeLoadout({
|
|
120
|
+
mcpScope: [{ server: "opentasks" }, { server: "ast-grep" }],
|
|
121
|
+
}),
|
|
122
|
+
template: makeTemplate(),
|
|
123
|
+
options: commonOptions(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(frontmatter.mcpServers).toEqual(["opentasks", "ast-grep"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("emits disallowedTools from exclude entries", () => {
|
|
130
|
+
const { frontmatter } = materializeLoadout({
|
|
131
|
+
role: makeRole(),
|
|
132
|
+
loadout: makeLoadout({
|
|
133
|
+
mcpScope: [
|
|
134
|
+
{ server: "ast-grep", exclude: ["dangerous_replace", "reckless"] },
|
|
135
|
+
{ server: "chrome-devtools" },
|
|
136
|
+
],
|
|
137
|
+
}),
|
|
138
|
+
template: makeTemplate(),
|
|
139
|
+
options: commonOptions(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(frontmatter.mcpServers).toEqual(["ast-grep", "chrome-devtools"]);
|
|
143
|
+
expect(frontmatter.disallowedTools).toEqual([
|
|
144
|
+
"mcp__ast-grep__dangerous_replace",
|
|
145
|
+
"mcp__ast-grep__reckless",
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("records tools allowlists in the scope file for hook enforcement", () => {
|
|
150
|
+
const { frontmatter, scopeFile } = materializeLoadout({
|
|
151
|
+
role: makeRole(),
|
|
152
|
+
loadout: makeLoadout({
|
|
153
|
+
mcpScope: [
|
|
154
|
+
{ server: "chrome-devtools", tools: ["navigate", "screenshot"] },
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
template: makeTemplate(),
|
|
158
|
+
options: commonOptions(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(frontmatter.mcpServers).toEqual(["chrome-devtools"]);
|
|
162
|
+
expect(frontmatter.hooks).toBeDefined();
|
|
163
|
+
expect(scopeFile.scope).toEqual([
|
|
164
|
+
{ server: "chrome-devtools", tools: ["navigate", "screenshot"] },
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ────────────────────────────────────────────────────────────
|
|
170
|
+
// Install specs
|
|
171
|
+
// ────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("materializeLoadout — install specs and refs", () => {
|
|
174
|
+
it("emits inline install specs as object entries with a warning", () => {
|
|
175
|
+
const { frontmatter, warnings } = materializeLoadout({
|
|
176
|
+
role: makeRole(),
|
|
177
|
+
loadout: makeLoadout({
|
|
178
|
+
mcpServers: [
|
|
179
|
+
{ name: "bespoke", command: "node", args: ["./my.js"] },
|
|
180
|
+
],
|
|
181
|
+
mcpScope: [],
|
|
182
|
+
}),
|
|
183
|
+
template: makeTemplate(),
|
|
184
|
+
options: commonOptions(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(frontmatter.mcpServers).toEqual([
|
|
188
|
+
{ bespoke: { command: "node", args: ["./my.js"] } },
|
|
189
|
+
]);
|
|
190
|
+
expect(warnings.some((w) => w.includes("bespoke"))).toBe(true);
|
|
191
|
+
expect(warnings.some((w) => w.includes("subprocess"))).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("captures refs separately via resolveMcpScope (not placed in frontmatter)", () => {
|
|
195
|
+
const warnings = [];
|
|
196
|
+
const res = resolveMcpScope({
|
|
197
|
+
mcpScope: [],
|
|
198
|
+
mcpInstalls: [
|
|
199
|
+
{ ref: "@openhive/secrets-scanner", config: { apiKey: "$SECRET" } },
|
|
200
|
+
],
|
|
201
|
+
warnings,
|
|
202
|
+
roleName: "reviewer",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(res.mcpServers).toEqual([]);
|
|
206
|
+
expect(res.refs).toEqual([
|
|
207
|
+
{ ref: "@openhive/secrets-scanner", config: { apiKey: "$SECRET" } },
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not duplicate servers when install + scope reference the same name", () => {
|
|
212
|
+
const warnings = [];
|
|
213
|
+
const res = resolveMcpScope({
|
|
214
|
+
mcpScope: [{ server: "opentasks" }],
|
|
215
|
+
mcpInstalls: [
|
|
216
|
+
{ name: "opentasks", command: "node", args: ["./ot.js"] },
|
|
217
|
+
],
|
|
218
|
+
warnings,
|
|
219
|
+
roleName: "reviewer",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(res.mcpServers).toHaveLength(1);
|
|
223
|
+
// Install entry emits the inline form; the bare scope ref is collapsed into it.
|
|
224
|
+
expect(res.mcpServers[0]).toEqual({
|
|
225
|
+
opentasks: { command: "node", args: ["./ot.js"] },
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ────────────────────────────────────────────────────────────
|
|
231
|
+
// Permissions → scope file
|
|
232
|
+
// ────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
describe("materializeLoadout — permissions flow to scope file", () => {
|
|
235
|
+
it("copies allow/deny/ask lists to scopeFile.permissions", () => {
|
|
236
|
+
const { scopeFile } = materializeLoadout({
|
|
237
|
+
role: makeRole(),
|
|
238
|
+
loadout: makeLoadout({
|
|
239
|
+
permissions: {
|
|
240
|
+
allow: ["Read(**)"],
|
|
241
|
+
deny: ["Bash(git push:*)"],
|
|
242
|
+
ask: ["Write(.env)"],
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
template: makeTemplate(),
|
|
246
|
+
options: commonOptions(),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(scopeFile.permissions).toEqual({
|
|
250
|
+
allow: ["Read(**)"],
|
|
251
|
+
deny: ["Bash(git push:*)"],
|
|
252
|
+
ask: ["Write(.env)"],
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("leaves empty-list fields populated as [] (not undefined)", () => {
|
|
257
|
+
const { scopeFile } = materializeLoadout({
|
|
258
|
+
role: makeRole(),
|
|
259
|
+
loadout: makeLoadout({ permissions: {} }),
|
|
260
|
+
template: makeTemplate(),
|
|
261
|
+
options: commonOptions(),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(scopeFile.permissions.allow).toEqual([]);
|
|
265
|
+
expect(scopeFile.permissions.deny).toEqual([]);
|
|
266
|
+
expect(scopeFile.permissions.ask).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ────────────────────────────────────────────────────────────
|
|
271
|
+
// Hooks — only when needed
|
|
272
|
+
// ────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
describe("scopeNeedsHook", () => {
|
|
275
|
+
it("returns true when any scope entry has tools", () => {
|
|
276
|
+
expect(
|
|
277
|
+
scopeNeedsHook(
|
|
278
|
+
{ scope: [{ server: "x", tools: ["a"] }] },
|
|
279
|
+
{}
|
|
280
|
+
)
|
|
281
|
+
).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("returns true when loadout has permissions.allow/deny/ask", () => {
|
|
285
|
+
expect(
|
|
286
|
+
scopeNeedsHook({ scope: [] }, { permissions: { allow: ["Read(**)"] } })
|
|
287
|
+
).toBe(true);
|
|
288
|
+
expect(
|
|
289
|
+
scopeNeedsHook({ scope: [] }, { permissions: { deny: ["X"] } })
|
|
290
|
+
).toBe(true);
|
|
291
|
+
expect(
|
|
292
|
+
scopeNeedsHook({ scope: [] }, { permissions: { ask: ["Y"] } })
|
|
293
|
+
).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("returns false when only bare server refs + empty permissions", () => {
|
|
297
|
+
expect(
|
|
298
|
+
scopeNeedsHook({ scope: [{ server: "x" }] }, { permissions: {} })
|
|
299
|
+
).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("returns false for exclude-only scope (handled by disallowedTools)", () => {
|
|
303
|
+
expect(
|
|
304
|
+
scopeNeedsHook(
|
|
305
|
+
{ scope: [{ server: "x", exclude: ["y"] }] },
|
|
306
|
+
{ permissions: {} }
|
|
307
|
+
)
|
|
308
|
+
).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("materializeLoadout — hook frontmatter emission", () => {
|
|
313
|
+
it("omits hooks when scope needs no enforcement", () => {
|
|
314
|
+
const { frontmatter } = materializeLoadout({
|
|
315
|
+
role: makeRole(),
|
|
316
|
+
loadout: makeLoadout({
|
|
317
|
+
mcpScope: [{ server: "opentasks" }],
|
|
318
|
+
}),
|
|
319
|
+
template: makeTemplate(),
|
|
320
|
+
options: commonOptions(),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(frontmatter.hooks).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("emits PreToolUse hook with env vars pointing at scope file", () => {
|
|
327
|
+
const { frontmatter } = materializeLoadout({
|
|
328
|
+
role: makeRole(),
|
|
329
|
+
loadout: makeLoadout({
|
|
330
|
+
mcpScope: [{ server: "chrome-devtools", tools: ["navigate"] }],
|
|
331
|
+
}),
|
|
332
|
+
template: makeTemplate(),
|
|
333
|
+
options: commonOptions(),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(frontmatter.hooks?.PreToolUse).toBeDefined();
|
|
337
|
+
const entry = frontmatter.hooks.PreToolUse[0];
|
|
338
|
+
expect(entry.matcher).toBe("mcp__.*");
|
|
339
|
+
expect(entry.hooks[0].type).toBe("command");
|
|
340
|
+
expect(entry.hooks[0].env.SCOPE_FILE).toBe(
|
|
341
|
+
".swarm/claude-swarm/tmp/teams/loadout-demo/scope/reviewer.json"
|
|
342
|
+
);
|
|
343
|
+
expect(entry.hooks[0].env.ROLE_NAME).toBe("reviewer");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("omits hook block when hookCommand or scopeFilePath missing", () => {
|
|
347
|
+
const { frontmatter } = materializeLoadout({
|
|
348
|
+
role: makeRole(),
|
|
349
|
+
loadout: makeLoadout({
|
|
350
|
+
mcpScope: [{ server: "chrome-devtools", tools: ["navigate"] }],
|
|
351
|
+
}),
|
|
352
|
+
template: makeTemplate(),
|
|
353
|
+
options: commonOptions({ hookCommand: null, scopeFilePath: null }),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
expect(frontmatter.hooks).toBeUndefined();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ────────────────────────────────────────────────────────────
|
|
361
|
+
// Capabilities pass-through
|
|
362
|
+
// ────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
describe("materializeLoadout — capabilities", () => {
|
|
365
|
+
it("copies loadout.capabilities to frontmatter", () => {
|
|
366
|
+
const { frontmatter } = materializeLoadout({
|
|
367
|
+
role: makeRole(),
|
|
368
|
+
loadout: makeLoadout({
|
|
369
|
+
capabilities: ["file.read", "git.diff", "exec.test"],
|
|
370
|
+
}),
|
|
371
|
+
template: makeTemplate(),
|
|
372
|
+
options: commonOptions(),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(frontmatter.capabilities).toEqual([
|
|
376
|
+
"file.read",
|
|
377
|
+
"git.diff",
|
|
378
|
+
"exec.test",
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ────────────────────────────────────────────────────────────
|
|
384
|
+
// Provider map flexibility
|
|
385
|
+
// ────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
describe("provider-map acceptance", () => {
|
|
388
|
+
it("accepts template.mcpProviders as Map", () => {
|
|
389
|
+
const { frontmatter } = materializeLoadout({
|
|
390
|
+
role: makeRole(),
|
|
391
|
+
loadout: makeLoadout({ mcpScope: [{ server: "opentasks" }] }),
|
|
392
|
+
template: makeTemplate({
|
|
393
|
+
mcpProviders: new Map([
|
|
394
|
+
["opentasks", { command: "node", args: ["./ot.js"] }],
|
|
395
|
+
]),
|
|
396
|
+
}),
|
|
397
|
+
options: commonOptions(),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(frontmatter.mcpServers).toEqual(["opentasks"]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("accepts template.mcpProviders as plain object (cached JSON form)", () => {
|
|
404
|
+
const { frontmatter } = materializeLoadout({
|
|
405
|
+
role: makeRole(),
|
|
406
|
+
loadout: makeLoadout({ mcpScope: [{ server: "opentasks" }] }),
|
|
407
|
+
template: makeTemplate({
|
|
408
|
+
mcpProviders: { opentasks: { command: "node", args: ["./ot.js"] } },
|
|
409
|
+
}),
|
|
410
|
+
options: commonOptions(),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(frontmatter.mcpServers).toEqual(["opentasks"]);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ────────────────────────────────────────────────────────────
|
|
418
|
+
// Ordering + determinism
|
|
419
|
+
// ────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
describe("frontmatter order + shape", () => {
|
|
422
|
+
it("places identity keys before tool/scope keys for readability", () => {
|
|
423
|
+
const { frontmatter } = materializeLoadout({
|
|
424
|
+
role: makeRole(),
|
|
425
|
+
loadout: makeLoadout({
|
|
426
|
+
mcpScope: [{ server: "opentasks" }],
|
|
427
|
+
capabilities: ["file.read"],
|
|
428
|
+
}),
|
|
429
|
+
template: makeTemplate(),
|
|
430
|
+
options: commonOptions(),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const keys = Object.keys(frontmatter);
|
|
434
|
+
expect(keys.indexOf("name")).toBeLessThan(keys.indexOf("tools"));
|
|
435
|
+
expect(keys.indexOf("team_name")).toBeLessThan(keys.indexOf("mcpServers"));
|
|
436
|
+
expect(keys.indexOf("role")).toBeLessThan(keys.indexOf("capabilities"));
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("emits project_path when provided", () => {
|
|
440
|
+
const { frontmatter } = materializeLoadout({
|
|
441
|
+
role: makeRole(),
|
|
442
|
+
loadout: makeLoadout(),
|
|
443
|
+
template: makeTemplate(),
|
|
444
|
+
options: commonOptions({ projectPath: "/Users/alice/proj" }),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(frontmatter.project_path).toBe("/Users/alice/proj");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("omits project_path when not provided", () => {
|
|
451
|
+
const { frontmatter } = materializeLoadout({
|
|
452
|
+
role: makeRole(),
|
|
453
|
+
loadout: makeLoadout(),
|
|
454
|
+
template: makeTemplate(),
|
|
455
|
+
options: commonOptions({ projectPath: undefined }),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect("project_path" in frontmatter).toBe(false);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ────────────────────────────────────────────────────────────
|
|
463
|
+
// End-to-end — loadout-demo shape
|
|
464
|
+
// ────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
describe("end-to-end — loadout-demo reviewer role", () => {
|
|
467
|
+
it("materializes a realistic inline-extended loadout", () => {
|
|
468
|
+
// Mimics the reviewer role from openteams/examples/loadout-demo/
|
|
469
|
+
const loadout = {
|
|
470
|
+
name: "__inline:reviewer",
|
|
471
|
+
description: "Security-focused extension of code-reviewer",
|
|
472
|
+
skills: {
|
|
473
|
+
profile: "security-engineer",
|
|
474
|
+
include: ["review-style-guide", "owasp-top-10", "secrets-detection"],
|
|
475
|
+
max_tokens: 30000,
|
|
476
|
+
},
|
|
477
|
+
capabilities: [
|
|
478
|
+
"file.read",
|
|
479
|
+
"git.diff",
|
|
480
|
+
"codebase.search",
|
|
481
|
+
"exec.test",
|
|
482
|
+
"task.update",
|
|
483
|
+
],
|
|
484
|
+
mcpServers: [{ ref: "@openhive/secrets-scanner" }],
|
|
485
|
+
mcpScope: [
|
|
486
|
+
{ server: "ast-grep" },
|
|
487
|
+
{ server: "chrome-devtools", tools: ["navigate", "screenshot", "get_page_text"] },
|
|
488
|
+
],
|
|
489
|
+
permissions: {
|
|
490
|
+
allow: ["Read(**)", "Bash(git diff:*)", "Bash(npm audit:*)"],
|
|
491
|
+
deny: ["Bash(git push:*)", "Bash(rm -rf:*)", "Bash(curl *:*)"],
|
|
492
|
+
},
|
|
493
|
+
promptAddendum: "## Review Mindset\n- Cite line numbers\n",
|
|
494
|
+
raw: {},
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const { frontmatter, scopeFile, warnings } = materializeLoadout({
|
|
498
|
+
role: makeRole(),
|
|
499
|
+
loadout,
|
|
500
|
+
template: makeTemplate({
|
|
501
|
+
mcpProviders: new Map([
|
|
502
|
+
["ast-grep", { command: "npx", args: ["ast-grep-mcp"] }],
|
|
503
|
+
["chrome-devtools", { command: "npx", args: ["chrome-devtools-mcp"] }],
|
|
504
|
+
]),
|
|
505
|
+
}),
|
|
506
|
+
options: commonOptions(),
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Name + identity
|
|
510
|
+
expect(frontmatter.name).toBe("loadout-demo-reviewer");
|
|
511
|
+
expect(frontmatter.team_name).toBe("loadout-demo");
|
|
512
|
+
|
|
513
|
+
// MCP scope: bare refs for both servers
|
|
514
|
+
expect(frontmatter.mcpServers).toEqual(["ast-grep", "chrome-devtools"]);
|
|
515
|
+
|
|
516
|
+
// No disallowedTools because no exclude fields on scope entries
|
|
517
|
+
expect(frontmatter.disallowedTools).toBeUndefined();
|
|
518
|
+
|
|
519
|
+
// Hook needed because chrome-devtools has a tools allowlist
|
|
520
|
+
expect(frontmatter.hooks?.PreToolUse).toBeDefined();
|
|
521
|
+
|
|
522
|
+
// Scope file: both servers, chrome-devtools with tool allowlist
|
|
523
|
+
expect(scopeFile.scope).toEqual([
|
|
524
|
+
{ server: "ast-grep" },
|
|
525
|
+
{
|
|
526
|
+
server: "chrome-devtools",
|
|
527
|
+
tools: ["navigate", "screenshot", "get_page_text"],
|
|
528
|
+
},
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
// Permissions flowed through
|
|
532
|
+
expect(scopeFile.permissions.deny).toContain("Bash(git push:*)");
|
|
533
|
+
expect(scopeFile.permissions.allow).toContain("Bash(npm audit:*)");
|
|
534
|
+
|
|
535
|
+
// Capabilities
|
|
536
|
+
expect(frontmatter.capabilities).toContain("task.update");
|
|
537
|
+
expect(frontmatter.capabilities).toContain("exec.test");
|
|
538
|
+
|
|
539
|
+
// No inline install warnings (only a ref)
|
|
540
|
+
expect(warnings.filter((w) => w.includes("subprocess"))).toEqual([]);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// ────────────────────────────────────────────────────────────
|
|
545
|
+
// buildScopeFile + buildFrontmatter directly
|
|
546
|
+
// ────────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
describe("buildScopeFile / buildFrontmatter — direct callers", () => {
|
|
549
|
+
it("buildScopeFile tolerates undefined loadout", () => {
|
|
550
|
+
const sf = buildScopeFile({
|
|
551
|
+
role: makeRole(),
|
|
552
|
+
loadout: undefined,
|
|
553
|
+
mcp: { scope: [] },
|
|
554
|
+
teamName: "t",
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(sf.role).toBe("reviewer");
|
|
558
|
+
expect(sf.permissions).toEqual({ allow: [], deny: [], ask: [] });
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("buildFrontmatter produces stable name format", () => {
|
|
562
|
+
const fm = buildFrontmatter({
|
|
563
|
+
role: { name: "planner" },
|
|
564
|
+
loadout: undefined,
|
|
565
|
+
teamName: "alpha",
|
|
566
|
+
mcp: { mcpServers: [], disallowedTools: [] },
|
|
567
|
+
scopeFilePath: null,
|
|
568
|
+
hookCommand: null,
|
|
569
|
+
projectPath: "/x",
|
|
570
|
+
nativeTools: ["Read"],
|
|
571
|
+
position: "root",
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
expect(fm.name).toBe("alpha-planner");
|
|
575
|
+
expect(fm.team_name).toBe("alpha");
|
|
576
|
+
expect(fm.position).toBe("root");
|
|
577
|
+
});
|
|
578
|
+
});
|