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,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
+ });