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,137 @@
1
+ /**
2
+ * Tests for dispatch thread nudge commands on the sidecar command handler.
3
+ *
4
+ * Covers Phase 7 of dispatch-inbox-threads:
5
+ * - nudge command stores nudge state
6
+ * - check-nudge returns and clears pending nudges
7
+ * - Multiple nudges accumulate independently
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from "vitest";
11
+ import { createCommandHandler, respond } from "../sidecar-server.mjs";
12
+
13
+ function createTestHandler() {
14
+ const registeredAgents = new Map();
15
+ return createCommandHandler(null, "swarm:test", registeredAgents, {
16
+ transportMode: "websocket",
17
+ });
18
+ }
19
+
20
+ function createFakeClient() {
21
+ let lastResponse = null;
22
+ return {
23
+ write(data) {
24
+ try {
25
+ lastResponse = JSON.parse(data.replace(/\n$/, ""));
26
+ } catch {
27
+ lastResponse = data;
28
+ }
29
+ },
30
+ writable: true,
31
+ getResponse() {
32
+ return lastResponse;
33
+ },
34
+ };
35
+ }
36
+
37
+ describe("sidecar nudge commands", () => {
38
+ let handler;
39
+
40
+ beforeEach(() => {
41
+ handler = createTestHandler();
42
+ });
43
+
44
+ it("check-nudge returns empty array when no nudges pending", async () => {
45
+ const client = createFakeClient();
46
+ await handler({ action: "check-nudge" }, client);
47
+
48
+ const resp = client.getResponse();
49
+ expect(resp.ok).toBe(true);
50
+ expect(resp.nudges).toEqual([]);
51
+ });
52
+
53
+ it("nudge stores state, check-nudge returns and clears it", async () => {
54
+ const fakeClient = { write: () => {}, writable: true };
55
+
56
+ // Store a nudge
57
+ await handler(
58
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-d1" },
59
+ fakeClient,
60
+ );
61
+
62
+ // Check nudge should return it
63
+ const client = createFakeClient();
64
+ await handler({ action: "check-nudge" }, client);
65
+
66
+ const resp = client.getResponse();
67
+ expect(resp.ok).toBe(true);
68
+ expect(resp.nudges).toHaveLength(1);
69
+ expect(resp.nudges[0]).toEqual({
70
+ dispatch_id: "d1",
71
+ conversation_id: "conv-d1",
72
+ });
73
+
74
+ // Second check should be empty (cleared)
75
+ const client2 = createFakeClient();
76
+ await handler({ action: "check-nudge" }, client2);
77
+
78
+ const resp2 = client2.getResponse();
79
+ expect(resp2.nudges).toEqual([]);
80
+ });
81
+
82
+ it("accumulates multiple nudges for different dispatches", async () => {
83
+ const fakeClient = { write: () => {}, writable: true };
84
+
85
+ await handler(
86
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-d1" },
87
+ fakeClient,
88
+ );
89
+ await handler(
90
+ { action: "nudge", dispatch_id: "d2", conversation_id: "conv-d2" },
91
+ fakeClient,
92
+ );
93
+
94
+ const client = createFakeClient();
95
+ await handler({ action: "check-nudge" }, client);
96
+
97
+ const resp = client.getResponse();
98
+ expect(resp.nudges).toHaveLength(2);
99
+ const ids = resp.nudges.map((n) => n.dispatch_id).sort();
100
+ expect(ids).toEqual(["d1", "d2"]);
101
+ });
102
+
103
+ it("overwrites nudge for same dispatch_id (latest wins)", async () => {
104
+ const fakeClient = { write: () => {}, writable: true };
105
+
106
+ await handler(
107
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-old" },
108
+ fakeClient,
109
+ );
110
+ await handler(
111
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-new" },
112
+ fakeClient,
113
+ );
114
+
115
+ const client = createFakeClient();
116
+ await handler({ action: "check-nudge" }, client);
117
+
118
+ const resp = client.getResponse();
119
+ expect(resp.nudges).toHaveLength(1);
120
+ expect(resp.nudges[0].conversation_id).toBe("conv-new");
121
+ });
122
+
123
+ it("ignores nudge with no dispatch_id", async () => {
124
+ const fakeClient = { write: () => {}, writable: true };
125
+
126
+ await handler(
127
+ { action: "nudge", conversation_id: "conv-x" },
128
+ fakeClient,
129
+ );
130
+
131
+ const client = createFakeClient();
132
+ await handler({ action: "check-nudge" }, client);
133
+
134
+ const resp = client.getResponse();
135
+ expect(resp.nudges).toEqual([]);
136
+ });
137
+ });
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { parseSkillTreeExtension, inferProfileFromRole } from "../skilltree-client.mjs";
2
+ import {
3
+ parseSkillTreeExtension,
4
+ inferProfileFromRole,
5
+ computeRoleCriteria,
6
+ } from "../skilltree-client.mjs";
3
7
 
4
8
  describe("skilltree-client", () => {
5
9
  describe("parseSkillTreeExtension", () => {
@@ -123,4 +127,184 @@ describe("skilltree-client", () => {
123
127
  expect(inferProfileFromRole("quick-flow-dev")).toBe("implementation");
124
128
  });
125
129
  });
130
+
131
+ describe("computeRoleCriteria — priority chain", () => {
132
+ const baseManifest = { name: "t", roles: ["worker"] };
133
+
134
+ it("uses skilltree extension defaults when no per-role override", () => {
135
+ const out = computeRoleCriteria("worker", {
136
+ ...baseManifest,
137
+ skilltree: { defaults: { profile: "implementation", maxSkills: 4 } },
138
+ });
139
+ expect(out).toEqual({ profile: "implementation", maxSkills: 4 });
140
+ });
141
+
142
+ it("merges per-role override over defaults", () => {
143
+ const out = computeRoleCriteria("worker", {
144
+ ...baseManifest,
145
+ skilltree: {
146
+ defaults: { profile: "implementation", maxSkills: 4 },
147
+ roles: { worker: { profile: "code-review" } },
148
+ },
149
+ });
150
+ expect(out.profile).toBe("code-review");
151
+ expect(out.maxSkills).toBe(4);
152
+ });
153
+
154
+ it("openteams loadout.skills overlays on top of skilltree extension", () => {
155
+ const template = {
156
+ roles: new Map([
157
+ ["worker", { loadout: { skills: { profile: "security" } } }],
158
+ ]),
159
+ };
160
+ const out = computeRoleCriteria(
161
+ "worker",
162
+ {
163
+ ...baseManifest,
164
+ skilltree: { defaults: { profile: "implementation" } },
165
+ },
166
+ {},
167
+ template,
168
+ );
169
+ // openteams wins on profile (replace-if-set)
170
+ expect(out.profile).toBe("security");
171
+ });
172
+
173
+ it("openteams include unions with skilltree extension include", () => {
174
+ const template = {
175
+ roles: new Map([
176
+ [
177
+ "worker",
178
+ { loadout: { skills: { include: ["b", "c"] } } },
179
+ ],
180
+ ]),
181
+ };
182
+ const out = computeRoleCriteria(
183
+ "worker",
184
+ {
185
+ ...baseManifest,
186
+ skilltree: {
187
+ roles: { worker: { profile: "x", include: ["a", "b"] } },
188
+ },
189
+ },
190
+ {},
191
+ template,
192
+ );
193
+ expect([...out.include].sort()).toEqual(["a", "b", "c"]);
194
+ });
195
+
196
+ it("openteams max_tokens renames to maxTokens and replaces", () => {
197
+ const template = {
198
+ roles: new Map([
199
+ ["worker", { loadout: { skills: { max_tokens: 30000 } } }],
200
+ ]),
201
+ };
202
+ const out = computeRoleCriteria(
203
+ "worker",
204
+ {
205
+ ...baseManifest,
206
+ skilltree: { defaults: { profile: "x", maxTokens: 8000 } },
207
+ },
208
+ {},
209
+ template,
210
+ );
211
+ expect(out.maxTokens).toBe(30000);
212
+ });
213
+
214
+ it("falls back to config.defaultProfile when no skill-bearing criteria", () => {
215
+ const out = computeRoleCriteria(
216
+ "novel-role",
217
+ baseManifest,
218
+ { defaultProfile: "documentation" },
219
+ );
220
+ expect(out.profile).toBe("documentation");
221
+ });
222
+
223
+ it("falls back to inferProfileFromRole when no defaultProfile", () => {
224
+ const out = computeRoleCriteria("verifier", baseManifest);
225
+ expect(out.profile).toBe("testing");
226
+ });
227
+
228
+ it("returns null when no criteria can be derived", () => {
229
+ const out = computeRoleCriteria("brand-new-role-xyz", baseManifest);
230
+ expect(out).toBeNull();
231
+ });
232
+
233
+ it("does not invoke fallback chain when criteria already has tags", () => {
234
+ const out = computeRoleCriteria("worker", {
235
+ ...baseManifest,
236
+ skilltree: { defaults: { tags: ["typescript"] } },
237
+ });
238
+ expect(out.tags).toEqual(["typescript"]);
239
+ expect(out.profile).toBeUndefined();
240
+ });
241
+
242
+ it("does not invoke fallback chain when openteams provided include", () => {
243
+ const template = {
244
+ roles: new Map([
245
+ ["worker", { loadout: { skills: { include: ["x"] } } }],
246
+ ]),
247
+ };
248
+ const out = computeRoleCriteria("worker", baseManifest, {}, template);
249
+ expect(out.include).toEqual(["x"]);
250
+ // No fallback profile assigned
251
+ expect(out.profile).toBeUndefined();
252
+ });
253
+
254
+ it("accepts template.roles as a plain object (not just Map)", () => {
255
+ const template = {
256
+ roles: { worker: { loadout: { skills: { profile: "security" } } } },
257
+ };
258
+ const out = computeRoleCriteria("worker", baseManifest, {}, template);
259
+ expect(out.profile).toBe("security");
260
+ });
261
+
262
+ it("openteams overlay precedes fallback (no defaultProfile applied if openteams set profile)", () => {
263
+ const template = {
264
+ roles: new Map([
265
+ ["worker", { loadout: { skills: { profile: "security" } } }],
266
+ ]),
267
+ };
268
+ const out = computeRoleCriteria(
269
+ "worker",
270
+ baseManifest,
271
+ { defaultProfile: "implementation" },
272
+ template,
273
+ );
274
+ expect(out.profile).toBe("security");
275
+ });
276
+
277
+ it("preserves criteria fields openteams does not declare", () => {
278
+ const template = {
279
+ roles: new Map([
280
+ [
281
+ "worker",
282
+ { loadout: { skills: { profile: "security" } } },
283
+ ],
284
+ ]),
285
+ };
286
+ const out = computeRoleCriteria(
287
+ "worker",
288
+ {
289
+ ...baseManifest,
290
+ skilltree: {
291
+ roles: {
292
+ worker: {
293
+ profile: "x",
294
+ tags: ["typescript"],
295
+ keywords: ["frontend"],
296
+ maxSkills: 6,
297
+ },
298
+ },
299
+ },
300
+ },
301
+ {},
302
+ template,
303
+ );
304
+ expect(out.profile).toBe("security");
305
+ expect(out.tags).toEqual(["typescript"]);
306
+ expect(out.keywords).toEqual(["frontend"]);
307
+ expect(out.maxSkills).toBe(6);
308
+ });
309
+ });
126
310
  });
@@ -7,7 +7,16 @@
7
7
 
8
8
  import fs from "fs";
9
9
  import path from "path";
10
+ import yaml from "js-yaml";
10
11
  import { buildCapabilitiesContext } from "./context-output.mjs";
12
+ import { materializeLoadout } from "./loadout-materializer.mjs";
13
+
14
+ /**
15
+ * Default command path for the scope-check PreToolUse hook.
16
+ * Claude Code expands ${CLAUDE_PLUGIN_ROOT} at runtime.
17
+ */
18
+ const DEFAULT_HOOK_COMMAND =
19
+ "${CLAUDE_PLUGIN_ROOT}/scripts/scope-check.mjs";
11
20
 
12
21
  /**
13
22
  * Parse team.yaml without js-yaml dependency (basic YAML subset).
@@ -43,6 +52,67 @@ export function parseBasicYaml(content) {
43
52
  return result;
44
53
  }
45
54
 
55
+ /**
56
+ * Build the frontmatter lines for an AGENT.md file.
57
+ *
58
+ * When `loadout` is provided, uses the loadout-materializer to produce
59
+ * enriched frontmatter (mcpServers, disallowedTools, hooks, capabilities).
60
+ * Otherwise falls back to the minimal legacy shape (name, description,
61
+ * model, tools).
62
+ *
63
+ * Returns an array of lines (no leading/trailing `---`).
64
+ */
65
+ export function buildAgentFrontmatterLines({
66
+ roleName,
67
+ teamName,
68
+ description,
69
+ model,
70
+ tools,
71
+ loadout,
72
+ template,
73
+ hookCommand,
74
+ scopeFilePath,
75
+ projectPath,
76
+ position,
77
+ }) {
78
+ if (!loadout) {
79
+ // Legacy minimal frontmatter — preserved verbatim for back-compat.
80
+ const lines = [];
81
+ lines.push(`name: ${teamName}-${roleName}`);
82
+ lines.push(`description: "${(description ?? "").replace(/"/g, '\\"')}"`);
83
+ if (model) lines.push(`model: ${model}`);
84
+ if (tools && tools.length > 0) {
85
+ lines.push(`tools: [${tools.join(", ")}]`);
86
+ }
87
+ return lines;
88
+ }
89
+
90
+ // Loadout-enriched frontmatter via materializer.
91
+ const { frontmatter } = materializeLoadout({
92
+ role: { name: roleName, description },
93
+ loadout,
94
+ template,
95
+ options: {
96
+ teamName,
97
+ hookCommand,
98
+ scopeFilePath,
99
+ projectPath,
100
+ nativeTools: tools,
101
+ position,
102
+ },
103
+ });
104
+ if (model) frontmatter.model = model;
105
+
106
+ // Serialize via js-yaml and split into lines so the caller can emit
107
+ // them between `---` fences.
108
+ const dumped = yaml.dump(frontmatter, {
109
+ lineWidth: 120,
110
+ noRefs: true,
111
+ sortKeys: false,
112
+ });
113
+ return dumped.replace(/\n+$/, "").split("\n");
114
+ }
115
+
46
116
  /**
47
117
  * Determine which tools an agent needs based on role name, manifest, and position.
48
118
  */
@@ -79,6 +149,11 @@ export function determineTools(roleName, manifest, position, options = {}) {
79
149
 
80
150
  /**
81
151
  * Generate a Claude Code AGENT.md file content for a single role.
152
+ *
153
+ * When `loadout` is provided, the frontmatter is enriched via the
154
+ * loadout-materializer — adds mcpServers, disallowedTools, hooks,
155
+ * capabilities, and generator marker fields. When omitted, falls back
156
+ * to the legacy minimal frontmatter shape.
82
157
  */
83
158
  export function generateAgentMd({
84
159
  roleName,
@@ -103,19 +178,30 @@ export function generateAgentMd({
103
178
  mapEnabled,
104
179
  mapStatus,
105
180
  sessionlogSync,
181
+ // Loadout materialization (all optional — fall back to legacy frontmatter if absent)
182
+ loadout,
183
+ template,
184
+ hookCommand,
185
+ scopeFilePath,
186
+ projectPath,
106
187
  }) {
107
188
  const lines = [];
108
189
 
109
190
  // Claude Code AGENT.md frontmatter
110
191
  lines.push("---");
111
- lines.push(`name: ${teamName}-${roleName}`);
112
- lines.push(`description: "${description.replace(/"/g, '\\"')}"`);
113
- if (model) {
114
- lines.push(`model: ${model}`);
115
- }
116
- if (tools && tools.length > 0) {
117
- lines.push(`tools: [${tools.join(", ")}]`);
118
- }
192
+ lines.push(...buildAgentFrontmatterLines({
193
+ roleName,
194
+ teamName,
195
+ description,
196
+ model,
197
+ tools,
198
+ loadout,
199
+ template,
200
+ hookCommand: hookCommand || DEFAULT_HOOK_COMMAND,
201
+ scopeFilePath,
202
+ projectPath,
203
+ position,
204
+ }));
119
205
  lines.push("---");
120
206
  lines.push("");
121
207
 
@@ -292,6 +378,41 @@ export async function generateAllAgents(templateDir, outputDir, options = {}) {
292
378
 
293
379
  const tools = determineTools(roleName, manifest, position, options);
294
380
 
381
+ // Compute the scope file path the per-agent hook will read at runtime.
382
+ // Default: sibling of the agents output dir (<output>/../scope/<role>.json).
383
+ // When the role has a loadout, we ALSO write the scope file there so
384
+ // the frontmatter's SCOPE_FILE path always resolves at runtime.
385
+ const scopeDirAbs = options.scopeBasePath
386
+ ? path.resolve(options.scopeBasePath)
387
+ : path.resolve(absOutputDir, "..", "scope");
388
+ const scopeFileAbs = path.join(scopeDirAbs, `${roleName}.json`);
389
+ const scopeFilePath = options.projectPath
390
+ ? path.relative(options.projectPath, scopeFileAbs)
391
+ : scopeFileAbs;
392
+
393
+ if (role?.loadout) {
394
+ try {
395
+ const { scopeFile: scopeFileContent } = materializeLoadout({
396
+ role: {
397
+ name: roleName,
398
+ description: role.description,
399
+ displayName: role.displayName,
400
+ },
401
+ loadout: role.loadout,
402
+ template,
403
+ options: { teamName, scopeFilePath },
404
+ });
405
+ fs.mkdirSync(scopeDirAbs, { recursive: true });
406
+ fs.writeFileSync(
407
+ scopeFileAbs,
408
+ JSON.stringify(scopeFileContent, null, 2),
409
+ "utf-8"
410
+ );
411
+ } catch {
412
+ // Non-fatal — frontmatter still references the path; hook fails open
413
+ }
414
+ }
415
+
295
416
  const agentMd = generateAgentMd({
296
417
  roleName,
297
418
  teamName,
@@ -301,6 +422,12 @@ export async function generateAllAgents(templateDir, outputDir, options = {}) {
301
422
  tools,
302
423
  skillContent: roleSkill.content,
303
424
  manifest,
425
+ // Loadout integration (only kicks in when role.loadout is set in openteams)
426
+ loadout: role?.loadout,
427
+ template,
428
+ scopeFilePath,
429
+ projectPath: options.projectPath,
430
+ hookCommand: options.hookCommand,
304
431
  opentasksEnabled: options.opentasksEnabled,
305
432
  opentasksStatus: options.opentasksStatus,
306
433
  minimemEnabled: options.minimemEnabled,
package/src/bootstrap.mjs CHANGED
@@ -244,16 +244,22 @@ async function startSessionSidecar(config, scope, dir, sessionId) {
244
244
 
245
245
  const ok = await startSidecar(config, dir, sessionId);
246
246
  if (ok) {
247
- // Register the main Claude Code session agent with the MAP server
247
+ // Register the main Claude Code session agent with the MAP server.
248
+ // Use the inbox-derived ID (`${teamName}-main`) as the canonical agentId
249
+ // so MAP and inbox identities are unified — the hub can correlate a MAP
250
+ // agent with its inbox participant without a separate lookup table.
251
+ // The ephemeral sessionId is preserved in metadata for trajectory
252
+ // correlation and session storage.
248
253
  const teamName = resolveTeamName(config);
254
+ const inboxAgentId = `${teamName}-main`;
249
255
  sendCommand(config, {
250
256
  action: "spawn",
251
257
  agent: {
252
- agentId: sessionId,
253
- name: `${teamName}-main`,
258
+ agentId: inboxAgentId,
259
+ name: inboxAgentId,
254
260
  role: "orchestrator",
255
261
  scopes: [scope],
256
- metadata: { isMain: true, sessionId },
262
+ metadata: { isMain: true, sessionId, inboxAgentId },
257
263
  },
258
264
  }, sessionId).catch(() => {});
259
265
 
@@ -307,9 +313,11 @@ export async function backgroundInit(config, scope, dir, sessionId) {
307
313
  }
308
314
  }
309
315
 
310
- // Inbox registration
316
+ // Inbox registration — uses the same stable ID as the MAP registration
317
+ // above so both systems share a single canonical agent identity.
311
318
  if (config.map.enabled && config.inbox?.enabled) {
312
- const teamName = resolveTeamName(config);
319
+ const inboxTeamName = resolveTeamName(config);
320
+ const inboxId = `${inboxTeamName}-main`;
313
321
  const sPaths = sessionId
314
322
  ? (await import("./paths.mjs")).sessionPaths(sessionId)
315
323
  : { inboxSocketPath: (await import("./paths.mjs")).INBOX_SOCKET_PATH };
@@ -319,11 +327,11 @@ export async function backgroundInit(config, scope, dir, sessionId) {
319
327
  event: {
320
328
  type: "agent.spawn",
321
329
  agent: {
322
- agentId: `${teamName}-main`,
323
- name: `${teamName}-main`,
330
+ agentId: inboxId,
331
+ name: inboxId,
324
332
  role: "orchestrator",
325
333
  scopes: [scope],
326
- metadata: { isMain: true, sessionId },
334
+ metadata: { isMain: true, sessionId, inboxAgentId: inboxId },
327
335
  },
328
336
  },
329
337
  }, sPaths.inboxSocketPath).catch(() => {})
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import fs from "fs";
10
+ import path from "path";
11
+ import { formatHealthReport } from "./mcp-health-checker.mjs";
10
12
 
11
13
  // ── Capabilities context (shared between main agent + spawned agents) ────────
12
14
 
@@ -259,6 +261,36 @@ export function formatBootstrapContext({
259
261
 
260
262
  lines.push(`Agent prompts: \`${team.outputDir}/agents/<role>.md\``);
261
263
  lines.push("");
264
+
265
+ // MCP health report (from loadout-aware caching) — non-blocking, informational.
266
+ // Only surface when the team declared providers or any loadout referenced
267
+ // a server; otherwise skip silently to avoid noise in non-loadout templates.
268
+ try {
269
+ const healthPath = path.join(team.outputDir, "mcp-health.json");
270
+ if (fs.existsSync(healthPath)) {
271
+ const report = JSON.parse(fs.readFileSync(healthPath, "utf-8"));
272
+ const anything =
273
+ (report.ok?.length ?? 0) +
274
+ (report.missing?.length ?? 0) +
275
+ (report.refs?.length ?? 0) +
276
+ (report.disabled?.length ?? 0) +
277
+ (report.orphanedReferences?.length ?? 0);
278
+ if (anything > 0) {
279
+ const rendered = formatHealthReport(report, { teamName: team.teamName });
280
+ lines.push(rendered);
281
+ lines.push("");
282
+ if (report.missing?.length > 0 || report.orphanedReferences?.length > 0) {
283
+ lines.push(
284
+ "Run `/swarm-mcp check` for details or `/swarm-mcp install <name>` " +
285
+ "to explicitly add a declared provider to your `.mcp.json`."
286
+ );
287
+ lines.push("");
288
+ }
289
+ }
290
+ }
291
+ } catch {
292
+ // Non-fatal — health is informational, never blocks bootstrap.
293
+ }
262
294
  }
263
295
 
264
296
  lines.push(