claude-code-swarm 0.3.23 → 0.3.25
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-sidecar.mjs +34 -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 +177 -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__/skilltree-client.test.mjs +185 -1
- package/src/agent-generator.mjs +135 -8
- package/src/context-output.mjs +32 -0
- package/src/loadout-materializer.mjs +315 -0
- package/src/mcp-health-checker.mjs +237 -0
- package/src/opentasks-bridge.mjs +140 -0
- package/src/skilltree-client.mjs +135 -24
- package/src/template.mjs +158 -2
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
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
|
});
|
package/src/agent-generator.mjs
CHANGED
|
@@ -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(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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/context-output.mjs
CHANGED
|
@@ -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(
|