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,176 @@
1
+ /**
2
+ * Loadout schema bridge — tripwire test.
3
+ *
4
+ * Asserts that the openteams `loadout.skills` schema (SkillsConfig) and
5
+ * skilltree-client's bridge mapping stay in sync. skill-tree is the
6
+ * mechanism; openteams' `loadout.skills` is the declaration that
7
+ * dispatches into it.
8
+ *
9
+ * What this test catches:
10
+ * - openteams adds a new field to SkillsConfig and the bridge doesn't
11
+ * update OPENTEAMS_BRIDGED_FIELDS / mergeOpenteamsSkillsIntoCriteria.
12
+ * - The bridge claims to handle a field that openteams' schema no
13
+ * longer declares (drift the other direction).
14
+ * - The merge semantics regress (profile replace, include/exclude
15
+ * union-with-dedup, max_tokens → maxTokens rename).
16
+ */
17
+
18
+ import fs from "fs";
19
+ import path from "path";
20
+ import { fileURLToPath } from "url";
21
+ import { describe, it, expect } from "vitest";
22
+ import {
23
+ OPENTEAMS_BRIDGED_FIELDS,
24
+ mergeOpenteamsSkillsIntoCriteria,
25
+ } from "../skilltree-client.mjs";
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ /**
30
+ * Locate openteams' loadout schema. Prefers the sibling-repo layout used
31
+ * by the openhive monorepo (`references/openteams/...`); falls back to a
32
+ * node_modules install for cc-swarm checked out standalone.
33
+ */
34
+ function findOpenteamsSchema() {
35
+ const candidates = [
36
+ // sibling repo layout (e.g. openhive's references/)
37
+ path.resolve(__dirname, "../../../openteams/schema/loadout.schema.json"),
38
+ // npm install in cc-swarm itself
39
+ path.resolve(__dirname, "../../node_modules/openteams/schema/loadout.schema.json"),
40
+ // npm install one level up
41
+ path.resolve(__dirname, "../../../node_modules/openteams/schema/loadout.schema.json"),
42
+ ];
43
+ for (const p of candidates) {
44
+ if (fs.existsSync(p)) return p;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function readSkillsConfigFields() {
50
+ const schemaPath = findOpenteamsSchema();
51
+ if (!schemaPath) {
52
+ throw new Error(
53
+ "openteams loadout.schema.json not found — install openteams or check out as a sibling repo",
54
+ );
55
+ }
56
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
57
+ const props = schema?.$defs?.SkillsConfig?.properties;
58
+ if (!props) {
59
+ throw new Error(`SkillsConfig.properties missing in ${schemaPath}`);
60
+ }
61
+ return Object.keys(props);
62
+ }
63
+
64
+ describe("loadout schema bridge — openteams ↔ skill-tree", () => {
65
+ describe("schema convergence", () => {
66
+ it("OPENTEAMS_BRIDGED_FIELDS matches openteams SkillsConfig.properties", () => {
67
+ const schemaFields = readSkillsConfigFields();
68
+ const bridged = [...OPENTEAMS_BRIDGED_FIELDS];
69
+ expect(bridged.sort()).toEqual([...schemaFields].sort());
70
+ });
71
+
72
+ it("openteams SkillsConfig is closed (additionalProperties: false)", () => {
73
+ // The bridge relies on `properties` being the complete set. If
74
+ // openteams ever loosens this, the convergence guarantee breaks
75
+ // and the test above can pass while real loadouts carry fields
76
+ // we don't bridge.
77
+ const schemaPath = findOpenteamsSchema();
78
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
79
+ expect(schema.$defs.SkillsConfig.additionalProperties).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("mergeOpenteamsSkillsIntoCriteria", () => {
84
+ it("returns a copy when loadoutSkills is undefined", () => {
85
+ const input = { profile: "baseline", tags: ["a"] };
86
+ const out = mergeOpenteamsSkillsIntoCriteria(input, undefined);
87
+ expect(out).toEqual(input);
88
+ expect(out).not.toBe(input);
89
+ });
90
+
91
+ it("returns a copy when loadoutSkills is null", () => {
92
+ const input = { profile: "baseline" };
93
+ const out = mergeOpenteamsSkillsIntoCriteria(input, null);
94
+ expect(out).toEqual(input);
95
+ expect(out).not.toBe(input);
96
+ });
97
+
98
+ it("returns an empty object when both inputs are absent", () => {
99
+ const out = mergeOpenteamsSkillsIntoCriteria(undefined, undefined);
100
+ expect(out).toEqual({});
101
+ });
102
+
103
+ it("profile replaces (not merges) when set", () => {
104
+ const out = mergeOpenteamsSkillsIntoCriteria(
105
+ { profile: "baseline" },
106
+ { profile: "security" },
107
+ );
108
+ expect(out.profile).toBe("security");
109
+ });
110
+
111
+ it("profile preserved when openteams.skills.profile is empty", () => {
112
+ const out = mergeOpenteamsSkillsIntoCriteria(
113
+ { profile: "baseline" },
114
+ { include: ["x"] },
115
+ );
116
+ expect(out.profile).toBe("baseline");
117
+ });
118
+
119
+ it("include/exclude union-with-dedup", () => {
120
+ const out = mergeOpenteamsSkillsIntoCriteria(
121
+ { include: ["a", "b"], exclude: ["x"] },
122
+ { include: ["b", "c"], exclude: ["x", "y"] },
123
+ );
124
+ expect([...out.include].sort()).toEqual(["a", "b", "c"]);
125
+ expect([...out.exclude].sort()).toEqual(["x", "y"]);
126
+ });
127
+
128
+ it("max_tokens renames to maxTokens", () => {
129
+ const out = mergeOpenteamsSkillsIntoCriteria({}, { max_tokens: 4000 });
130
+ expect(out.maxTokens).toBe(4000);
131
+ expect(out.max_tokens).toBeUndefined();
132
+ });
133
+
134
+ it("max_tokens replaces when set", () => {
135
+ const out = mergeOpenteamsSkillsIntoCriteria(
136
+ { maxTokens: 8000 },
137
+ { max_tokens: 4000 },
138
+ );
139
+ expect(out.maxTokens).toBe(4000);
140
+ });
141
+
142
+ it("preserves criteria fields openteams does not declare", () => {
143
+ // skill-tree's LoadoutCriteria is a superset — fields like `tags`,
144
+ // `tagsAll`, `taskDescription`, `keywords` come from the
145
+ // skilltree: extension or defaultProfile path, not from openteams.
146
+ const out = mergeOpenteamsSkillsIntoCriteria(
147
+ {
148
+ profile: "baseline",
149
+ tags: ["typescript"],
150
+ tagsAll: ["safe"],
151
+ taskDescription: "audit a flow",
152
+ keywords: ["frontend"],
153
+ maxSkills: 6,
154
+ },
155
+ { profile: "security", include: ["must-have"] },
156
+ );
157
+ expect(out.profile).toBe("security");
158
+ expect(out.tags).toEqual(["typescript"]);
159
+ expect(out.tagsAll).toEqual(["safe"]);
160
+ expect(out.taskDescription).toBe("audit a flow");
161
+ expect(out.keywords).toEqual(["frontend"]);
162
+ expect(out.maxSkills).toBe(6);
163
+ expect(out.include).toEqual(["must-have"]);
164
+ });
165
+
166
+ it("does not mutate the input criteria object", () => {
167
+ const input = { profile: "baseline", include: ["a"] };
168
+ const before = JSON.stringify(input);
169
+ mergeOpenteamsSkillsIntoCriteria(input, {
170
+ profile: "security",
171
+ include: ["b"],
172
+ });
173
+ expect(JSON.stringify(input)).toBe(before);
174
+ });
175
+ });
176
+ });
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Full integration e2e — openteams declaration → cc-swarm bridge →
3
+ * skill-tree compile → rendered output.
4
+ *
5
+ * Sets up a real (file-backed) skill-tree skill bank in a temp dir,
6
+ * populates it with a couple of skills, then drives `compileAllRoleLoadouts`
7
+ * with a hand-built openteams ResolvedTemplate. Asserts that the bridge
8
+ * actually produces content for the role and that openteams field values
9
+ * (include, max_tokens) survive the chain into skill-tree's compile.
10
+ *
11
+ * This closes the seam between the unit-tested bridge and the existing
12
+ * cacheLoadoutArtifacts e2e — the compile path through skill-tree is
13
+ * never exercised by either of those.
14
+ *
15
+ * Skips when skill-tree is unavailable.
16
+ */
17
+
18
+ import fs from "fs";
19
+ import path from "path";
20
+ import os from "os";
21
+ import { fileURLToPath } from "url";
22
+ import {
23
+ describe,
24
+ it,
25
+ expect,
26
+ beforeAll,
27
+ beforeEach,
28
+ afterEach,
29
+ } from "vitest";
30
+ import { compileAllRoleLoadouts } from "../skilltree-client.mjs";
31
+
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+
34
+ function skillTreeAvailable() {
35
+ try {
36
+ const cwd = path.resolve(__dirname, "../..");
37
+ require.resolve("skill-tree", { paths: [cwd] });
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ const SKIP = !skillTreeAvailable();
45
+
46
+ function makeSkill(id, overrides = {}) {
47
+ const now = new Date();
48
+ return {
49
+ id,
50
+ name: overrides.name ?? id.replace(/-/g, " "),
51
+ version: "1.0.0",
52
+ description: overrides.description ?? `Test skill ${id}`,
53
+ instructions:
54
+ overrides.instructions ??
55
+ `# ${id}\n\nThis is a test skill body for ${id}.`,
56
+ author: "test",
57
+ tags: overrides.tags ?? ["test"],
58
+ createdAt: now,
59
+ updatedAt: now,
60
+ status: "active",
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe.skipIf(SKIP)(
66
+ "loadout compile e2e — openteams → cc-swarm → skill-tree",
67
+ () => {
68
+ /** @type {string} */
69
+ let basePath;
70
+ /** @type {any} */
71
+ let bank;
72
+
73
+ beforeAll(async () => {
74
+ // Sanity: importing skill-tree lazily mirrors how cc-swarm uses it.
75
+ const st = await import("skill-tree");
76
+ expect(st.createSkillBank).toBeDefined();
77
+ });
78
+
79
+ beforeEach(async () => {
80
+ basePath = fs.mkdtempSync(
81
+ path.join(os.tmpdir(), "loadout-compile-e2e-"),
82
+ );
83
+ const st = await import("skill-tree");
84
+ bank = st.createSkillBank({ storage: { basePath } });
85
+ await bank.initialize();
86
+ await bank.saveSkill(makeSkill("alpha-skill"));
87
+ await bank.saveSkill(makeSkill("beta-skill"));
88
+ await bank.saveSkill(makeSkill("gamma-skill"));
89
+ await bank.shutdown();
90
+ });
91
+
92
+ afterEach(() => {
93
+ fs.rmSync(basePath, { recursive: true, force: true });
94
+ });
95
+
96
+ it("openteams loadout.skills.include reaches skill-tree compile and produces content", async () => {
97
+ const manifest = { name: "test-team", roles: ["worker"] };
98
+ const template = {
99
+ roles: new Map([
100
+ [
101
+ "worker",
102
+ {
103
+ loadout: {
104
+ skills: { include: ["alpha-skill"] },
105
+ },
106
+ },
107
+ ],
108
+ ]),
109
+ };
110
+
111
+ const result = await compileAllRoleLoadouts(
112
+ manifest,
113
+ { basePath },
114
+ template,
115
+ );
116
+
117
+ expect(result.worker).toBeDefined();
118
+ expect(result.worker.content).toBeTruthy();
119
+ expect(result.worker.content.length).toBeGreaterThan(0);
120
+ // The included skill's id (or name) should appear somewhere in the
121
+ // rendered system prompt.
122
+ expect(result.worker.content.toLowerCase()).toContain("alpha");
123
+ });
124
+
125
+ it("openteams loadout.skills.max_tokens flows through to skill-tree's compiler", async () => {
126
+ // Verifies plumbing, not skill-tree's own budgeting algorithm.
127
+ // The strong claim is: a low budget must produce strictly less
128
+ // content than a high budget. If `max_tokens` were silently dropped
129
+ // by the bridge, both runs would render identically.
130
+ const manifest = { name: "test-team", roles: ["worker"] };
131
+ const include = ["alpha-skill", "beta-skill", "gamma-skill"];
132
+ const buildTemplate = (max_tokens) => ({
133
+ roles: new Map([
134
+ ["worker", { loadout: { skills: { include, max_tokens } } }],
135
+ ]),
136
+ });
137
+
138
+ const low = await compileAllRoleLoadouts(
139
+ manifest,
140
+ { basePath },
141
+ buildTemplate(1),
142
+ );
143
+ const high = await compileAllRoleLoadouts(
144
+ manifest,
145
+ { basePath },
146
+ buildTemplate(100000),
147
+ );
148
+
149
+ const lowLen = low.worker?.content?.length ?? 0;
150
+ const highLen = high.worker?.content?.length ?? 0;
151
+ expect(highLen).toBeGreaterThan(lowLen);
152
+ });
153
+
154
+ it("openteams loadout.skills.exclude filters skills out at compile time", async () => {
155
+ const manifest = { name: "test-team", roles: ["worker"] };
156
+ const templateWithExclude = {
157
+ roles: new Map([
158
+ [
159
+ "worker",
160
+ {
161
+ loadout: {
162
+ skills: {
163
+ include: ["alpha-skill", "beta-skill", "gamma-skill"],
164
+ exclude: ["beta-skill"],
165
+ },
166
+ },
167
+ },
168
+ ],
169
+ ]),
170
+ };
171
+
172
+ const result = await compileAllRoleLoadouts(
173
+ manifest,
174
+ { basePath },
175
+ templateWithExclude,
176
+ );
177
+ expect(result.worker).toBeDefined();
178
+ const content = result.worker.content.toLowerCase();
179
+ expect(content).toContain("alpha");
180
+ expect(content).toContain("gamma");
181
+ expect(content).not.toContain("beta");
182
+ });
183
+
184
+ it("returns empty result for role with no loadout, no skilltree extension, no inferable profile", async () => {
185
+ const manifest = { name: "test-team", roles: ["nonsense-role-xyz"] };
186
+ const result = await compileAllRoleLoadouts(
187
+ manifest,
188
+ { basePath },
189
+ null,
190
+ );
191
+ expect(result["nonsense-role-xyz"]).toBeUndefined();
192
+ });
193
+
194
+ it("openteams loadout.skills overlays skilltree extension when both present", async () => {
195
+ const manifest = {
196
+ name: "test-team",
197
+ roles: ["worker"],
198
+ skilltree: {
199
+ roles: { worker: { include: ["beta-skill"] } },
200
+ },
201
+ };
202
+ const template = {
203
+ roles: new Map([
204
+ [
205
+ "worker",
206
+ {
207
+ loadout: { skills: { include: ["alpha-skill"] } },
208
+ },
209
+ ],
210
+ ]),
211
+ };
212
+
213
+ const result = await compileAllRoleLoadouts(
214
+ manifest,
215
+ { basePath },
216
+ template,
217
+ );
218
+ expect(result.worker).toBeDefined();
219
+ // Both skills should appear — the bridge unions includes from
220
+ // both sources rather than replacing.
221
+ const content = result.worker.content.toLowerCase();
222
+ expect(content).toContain("alpha");
223
+ expect(content).toContain("beta");
224
+ });
225
+ },
226
+ );
227
+
228
+ // ────────────────────────────────────────────────────────────────
229
+ // Composite e2e — uses a real openteams template fixture, populates the
230
+ // skill bank with skills tagged to match skill-tree's built-in profiles,
231
+ // loads the template via TemplateLoader.load(), and runs the full chain.
232
+ //
233
+ // Closes three sub-gaps that the include-only tests above don't cover:
234
+ // 1. Profile-resolution path (setLoadoutFromProfile in compileRoleLoadout)
235
+ // 2. TemplateLoader → compileAllRoleLoadouts composite (no hand-built
236
+ // template — the input is what openteams' loader produces)
237
+ // 3. `extends:` chain resolution feeding the compile (auditor extends
238
+ // base-reviewer; child profile + inherited max_tokens both reach
239
+ // skill-tree)
240
+ // ────────────────────────────────────────────────────────────────
241
+
242
+ const FIXTURE_TEAM_DIR = path.resolve(
243
+ __dirname,
244
+ "fixtures",
245
+ "loadout-compile-team",
246
+ );
247
+
248
+ function openteamsAvailable() {
249
+ try {
250
+ const cwd = path.resolve(__dirname, "../..");
251
+ require.resolve("openteams", { paths: [cwd] });
252
+ return true;
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ const SKIP_COMPOSITE =
259
+ !skillTreeAvailable() ||
260
+ !openteamsAvailable() ||
261
+ !fs.existsSync(FIXTURE_TEAM_DIR);
262
+
263
+ describe.skipIf(SKIP_COMPOSITE)(
264
+ "loadout compile e2e — TemplateLoader + extends + profile composite",
265
+ () => {
266
+ /** @type {string} */
267
+ let basePath;
268
+
269
+ beforeEach(async () => {
270
+ basePath = fs.mkdtempSync(
271
+ path.join(os.tmpdir(), "loadout-composite-e2e-"),
272
+ );
273
+ const st = await import("skill-tree");
274
+ const bank = st.createSkillBank({ storage: { basePath } });
275
+ await bank.initialize();
276
+
277
+ // Skills tagged so skill-tree's built-in profiles select different
278
+ // subsets, letting the extends-resolution test confirm the child
279
+ // profile genuinely replaces the parent:
280
+ // - "code-review" profile (tags: review/quality/security/best-practices)
281
+ // → matches review-style, security-audit, best-practice-checker (3)
282
+ // - "security" profile (tagsAll: ['security'])
283
+ // → matches security-audit only (1)
284
+ //
285
+ // makeSkill's auto-derived name (id with hyphens → spaces) is what
286
+ // skill-tree's markdown renderer puts in the table; assertions
287
+ // match on the lowercase form.
288
+ await bank.saveSkill(makeSkill("review-style", { tags: ["review", "quality"] }));
289
+ await bank.saveSkill(makeSkill("security-audit", { tags: ["security", "review"] }));
290
+ await bank.saveSkill(makeSkill("best-practice-checker", { tags: ["quality", "best-practices"] }));
291
+ await bank.shutdown();
292
+ });
293
+
294
+ afterEach(() => {
295
+ fs.rmSync(basePath, { recursive: true, force: true });
296
+ });
297
+
298
+ it("Gap 1 — built-in profile from openteams loadout reaches setLoadoutFromProfile and selects matching skills", async () => {
299
+ // reviewer role → base-reviewer loadout → profile: code-review.
300
+ // compileRoleLoadout dispatches to setLoadoutFromProfile (profile is
301
+ // set), which uses the built-in code-review criteria (tags include
302
+ // 'review' / 'quality' / 'best-practices'). All three fixture
303
+ // skills should match.
304
+ const ot = await import("openteams");
305
+ const template = ot.TemplateLoader.load(FIXTURE_TEAM_DIR);
306
+ const manifest = readManifestFromTemplate(template);
307
+
308
+ const result = await compileAllRoleLoadouts(
309
+ manifest,
310
+ { basePath },
311
+ template,
312
+ );
313
+
314
+ expect(result.reviewer).toBeDefined();
315
+ expect(result.reviewer.profile).toBe("code-review");
316
+ expect(result.reviewer.content.length).toBeGreaterThan(0);
317
+
318
+ const lower = result.reviewer.content.toLowerCase();
319
+ // code-review profile picks up all three skills via tag-match.
320
+ // The renderer shows skill `name` in the table; makeSkill defaults
321
+ // name = id.replace('-', ' '), so we match on the spaced form.
322
+ expect(lower).toContain("review style");
323
+ expect(lower).toContain("best practice checker");
324
+ expect(lower).toContain("security audit");
325
+ });
326
+
327
+ it("Gap 2 — full TemplateLoader.load() → compileAllRoleLoadouts composite produces content for every loadout-bearing role", async () => {
328
+ const ot = await import("openteams");
329
+ const template = ot.TemplateLoader.load(FIXTURE_TEAM_DIR);
330
+ const manifest = readManifestFromTemplate(template);
331
+
332
+ const result = await compileAllRoleLoadouts(
333
+ manifest,
334
+ { basePath },
335
+ template,
336
+ );
337
+
338
+ // Every role in the fixture has a loadout, so every role should
339
+ // produce content.
340
+ expect(result.reviewer).toBeDefined();
341
+ expect(result.auditor).toBeDefined();
342
+ expect(result["inline-extender"]).toBeDefined();
343
+
344
+ // Profile selection per role
345
+ expect(result.reviewer.profile).toBe("code-review");
346
+ expect(result.auditor.profile).toBe("security");
347
+ expect(result["inline-extender"].profile).toBe("code-review");
348
+ });
349
+
350
+ it("Gap 3 — extends: chain resolves into the compile path (auditor's child profile wins, parent max_tokens inherited)", async () => {
351
+ const ot = await import("openteams");
352
+ const template = ot.TemplateLoader.load(FIXTURE_TEAM_DIR);
353
+ const manifest = readManifestFromTemplate(template);
354
+
355
+ // Sanity at the load layer: auditor's resolved skills carry the
356
+ // child's profile (security) AND the parent's max_tokens (30000).
357
+ const auditorRole = template.roles.get("auditor");
358
+ expect(auditorRole.loadout.skills.profile).toBe("security");
359
+ expect(auditorRole.loadout.skills.max_tokens).toBe(30000);
360
+
361
+ const result = await compileAllRoleLoadouts(
362
+ manifest,
363
+ { basePath },
364
+ template,
365
+ );
366
+
367
+ // auditor uses the security profile (replaces parent's code-review).
368
+ // security has tags: ['security'] + tagsAll: ['security'], so only
369
+ // security-audit (tagged 'security') matches — review-style and
370
+ // best-practice-checker do not.
371
+ expect(result.auditor).toBeDefined();
372
+ expect(result.auditor.profile).toBe("security");
373
+ const auditorContent = result.auditor.content.toLowerCase();
374
+ expect(auditorContent).toContain("security audit");
375
+ expect(auditorContent).not.toContain("review style");
376
+ expect(auditorContent).not.toContain("best practice checker");
377
+
378
+ // reviewer (parent profile = code-review) selects the broader set.
379
+ // Proves the two profiles compile differently and the extends
380
+ // override at load time genuinely reaches skill-tree.
381
+ const reviewerContent = result.reviewer.content.toLowerCase();
382
+ expect(reviewerContent).toContain("review style");
383
+ expect(reviewerContent).toContain("best practice checker");
384
+ // Reviewer's set is a strict superset of auditor's
385
+ expect(reviewerContent.length).toBeGreaterThan(auditorContent.length);
386
+ });
387
+
388
+ it("Gap 3 — inline extends: composes capabilities + inherits skills.profile + max_tokens through compile", async () => {
389
+ const ot = await import("openteams");
390
+ const template = ot.TemplateLoader.load(FIXTURE_TEAM_DIR);
391
+ const manifest = readManifestFromTemplate(template);
392
+
393
+ // inline-extender's loadout is declared inline as `extends: base-reviewer`
394
+ // with capabilities_add. Resolved skills should be base-reviewer's
395
+ // (no inline override).
396
+ const inlineRole = template.roles.get("inline-extender");
397
+ expect(inlineRole.loadout.skills.profile).toBe("code-review");
398
+ expect(inlineRole.loadout.skills.max_tokens).toBe(30000);
399
+ // capabilities_add merged into capabilities at load time
400
+ expect(inlineRole.loadout.capabilities).toContain("exec.run");
401
+ expect(inlineRole.loadout.capabilities).toContain("file.read"); // inherited
402
+
403
+ const result = await compileAllRoleLoadouts(
404
+ manifest,
405
+ { basePath },
406
+ template,
407
+ );
408
+
409
+ // Compiled with parent's profile, same as reviewer
410
+ expect(result["inline-extender"]).toBeDefined();
411
+ expect(result["inline-extender"].profile).toBe("code-review");
412
+ const inlineContent = result["inline-extender"].content.toLowerCase();
413
+ const reviewerContent = result.reviewer.content.toLowerCase();
414
+ // Same profile → same selected skill ids
415
+ expect(inlineContent).toContain("review style");
416
+ expect(inlineContent).toContain("best practice checker");
417
+ // Cross-check: same profile selects the same set. Compare key
418
+ // markers rather than byte-equality so whitespace/ordering doesn't
419
+ // make this brittle.
420
+ for (const marker of [
421
+ "review style",
422
+ "best practice checker",
423
+ "security audit",
424
+ ]) {
425
+ expect(inlineContent.includes(marker)).toBe(
426
+ reviewerContent.includes(marker),
427
+ );
428
+ }
429
+ });
430
+ },
431
+ );
432
+
433
+ /**
434
+ * Build a minimal team manifest from a ResolvedTemplate so the existing
435
+ * compileAllRoleLoadouts signature (which expects a manifest with
436
+ * `roles: string[]`) keeps working. The composite test loads via
437
+ * TemplateLoader, so no team.yaml roundtrip is needed for the manifest.
438
+ */
439
+ function readManifestFromTemplate(template) {
440
+ return {
441
+ name: template.manifest?.name ?? "fixture",
442
+ roles: [...template.roles.keys()],
443
+ };
444
+ }