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.
Files changed (30) 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-sidecar.mjs +34 -0
  7. package/scripts/scope-check.mjs +132 -0
  8. package/skills/swarm-mcp/SKILL.md +116 -0
  9. package/src/__tests__/cognitive-core-loadout-e2e.test.mjs +260 -0
  10. package/src/__tests__/e2e-loadout-demo.test.mjs +150 -0
  11. package/src/__tests__/fixtures/loadout-compile-team/loadouts/base-reviewer.yaml +16 -0
  12. package/src/__tests__/fixtures/loadout-compile-team/loadouts/extended-security.yaml +10 -0
  13. package/src/__tests__/fixtures/loadout-compile-team/roles/auditor.yaml +4 -0
  14. package/src/__tests__/fixtures/loadout-compile-team/roles/inline-extender.yaml +10 -0
  15. package/src/__tests__/fixtures/loadout-compile-team/roles/reviewer.yaml +4 -0
  16. package/src/__tests__/fixtures/loadout-compile-team/team.yaml +15 -0
  17. package/src/__tests__/loadout-materializer.test.mjs +578 -0
  18. package/src/__tests__/loadout-schema-bridge.test.mjs +177 -0
  19. package/src/__tests__/loadout-skilltree-compile-e2e.test.mjs +444 -0
  20. package/src/__tests__/loadout-template-shape.test.mjs +102 -0
  21. package/src/__tests__/mcp-health-checker.test.mjs +327 -0
  22. package/src/__tests__/scope-check.test.mjs +210 -0
  23. package/src/__tests__/skilltree-client.test.mjs +185 -1
  24. package/src/agent-generator.mjs +135 -8
  25. package/src/context-output.mjs +32 -0
  26. package/src/loadout-materializer.mjs +315 -0
  27. package/src/mcp-health-checker.mjs +237 -0
  28. package/src/opentasks-bridge.mjs +140 -0
  29. package/src/skilltree-client.mjs +135 -24
  30. package/src/template.mjs +158 -2
@@ -149,40 +149,137 @@ export function inferProfileFromRole(roleName) {
149
149
  return "";
150
150
  }
151
151
 
152
+ // ────────────────────────────────────────────────────────────────
153
+ // Openteams ↔ skill-tree bridge
154
+ //
155
+ // The bridge between openteams `loadout.skills` (SkillsConfig in the
156
+ // schema) and skill-tree's LoadoutCriteria. skill-tree is the
157
+ // *mechanism*; openteams is the *declaration layer* that dispatches
158
+ // into it. See openhive's docs/LOADOUT_INTEGRATION.md for the model.
159
+ //
160
+ // Bridged fields are locked in by src/__tests__/loadout-schema-bridge.test.mjs
161
+ // which cross-references this list against openteams' SkillsConfig schema.
162
+ // Adding a field on either side without updating the other fails the test.
163
+ // ────────────────────────────────────────────────────────────────
164
+
165
+ /** Fields on openteams `loadout.skills` that the bridge maps into
166
+ * LoadoutCriteria. Order is the order they appear in openteams'
167
+ * `loadout.schema.json` $defs.SkillsConfig.properties. */
168
+ export const OPENTEAMS_BRIDGED_FIELDS = Object.freeze([
169
+ "profile",
170
+ "include",
171
+ "exclude",
172
+ "max_tokens",
173
+ ]);
174
+
175
+ /**
176
+ * Overlay an openteams `loadout.skills` block onto a skill-tree
177
+ * `LoadoutCriteria`. Pure function — returns a new object, does not
178
+ * mutate the input.
179
+ *
180
+ * Mapping:
181
+ * profile → criteria.profile (replace if set)
182
+ * include → criteria.include (union, deduped)
183
+ * exclude → criteria.exclude (union, deduped)
184
+ * max_tokens → criteria.maxTokens (replace if set)
185
+ *
186
+ * Passing null/undefined for `loadoutSkills` returns a shallow copy of
187
+ * the input criteria unchanged.
188
+ */
189
+ export function mergeOpenteamsSkillsIntoCriteria(criteria, loadoutSkills) {
190
+ const merged = { ...(criteria ?? {}) };
191
+ if (!loadoutSkills) return merged;
192
+ if (loadoutSkills.profile) merged.profile = loadoutSkills.profile;
193
+ if (loadoutSkills.include?.length) {
194
+ merged.include = mergeUnique(merged.include, loadoutSkills.include);
195
+ }
196
+ if (loadoutSkills.exclude?.length) {
197
+ merged.exclude = mergeUnique(merged.exclude, loadoutSkills.exclude);
198
+ }
199
+ if (typeof loadoutSkills.max_tokens === "number") {
200
+ merged.maxTokens = loadoutSkills.max_tokens;
201
+ }
202
+ return merged;
203
+ }
204
+
205
+ /**
206
+ * Resolve the LoadoutCriteria for a single role. Pure function — no I/O.
207
+ * Returns null when no criteria can be derived (signals to the caller
208
+ * that the role should be skipped).
209
+ *
210
+ * Priority order:
211
+ * 1. team.yaml `skilltree:` extension defaults
212
+ * 2. team.yaml `skilltree:` extension per-role override (replaces step 1)
213
+ * 3. openteams `role.loadout.skills` overlay via mergeOpenteamsSkillsIntoCriteria
214
+ * (this is the openteams ↔ skill-tree bridge — declarations dispatch
215
+ * into the mechanism)
216
+ * 4. If no skill-bearing criteria exists yet:
217
+ * a. config.defaultProfile, else
218
+ * b. ROLE_PROFILE_MAP auto-inference from role name, else
219
+ * c. return null (no criteria — caller skips this role)
220
+ *
221
+ * @param {string} roleName
222
+ * @param {object} manifest - Parsed team.yaml manifest
223
+ * @param {object} [config] - Plugin config (skilltree section)
224
+ * @param {object} [template] - Optional openteams ResolvedTemplate
225
+ * @returns {object|null} LoadoutCriteria or null when role has no criteria
226
+ */
227
+ export function computeRoleCriteria(
228
+ roleName,
229
+ manifest,
230
+ config = {},
231
+ template = null,
232
+ ) {
233
+ const { defaults, roles: roleOverrides } = parseSkillTreeExtension(manifest);
234
+ const templateRoles = normalizeRolesMap(template?.roles);
235
+
236
+ // Step 1+2: skilltree extension defaults + per-role override
237
+ let roleCriteria = roleOverrides[roleName]
238
+ ? { ...defaults, ...roleOverrides[roleName] }
239
+ : { ...defaults };
240
+
241
+ // Step 3: openteams loadout.skills overlay (canonical bridge — openteams wins)
242
+ const loadoutSkills = templateRoles?.get(roleName)?.loadout?.skills;
243
+ roleCriteria = mergeOpenteamsSkillsIntoCriteria(roleCriteria, loadoutSkills);
244
+
245
+ // Step 4: fallback chain — only fires when nothing skill-bearing is set yet
246
+ if (
247
+ !roleCriteria.profile &&
248
+ !roleCriteria.tags &&
249
+ !roleCriteria.include &&
250
+ !roleCriteria.taskDescription
251
+ ) {
252
+ if (config?.defaultProfile) {
253
+ roleCriteria.profile = config.defaultProfile;
254
+ } else {
255
+ const inferred = inferProfileFromRole(roleName);
256
+ if (inferred) {
257
+ roleCriteria.profile = inferred;
258
+ } else {
259
+ return null;
260
+ }
261
+ }
262
+ }
263
+
264
+ return roleCriteria;
265
+ }
266
+
152
267
  /**
153
- * Compile skill loadouts for all roles in a team manifest.
154
- * Reads the skilltree extension from the manifest, compiles loadouts per role.
155
- * Returns metadata alongside content for richer agent context.
268
+ * Compile skill loadouts for all roles in a team manifest. Thin
269
+ * orchestrator over `computeRoleCriteria` + `compileRoleLoadout`.
156
270
  *
157
271
  * @param {object} manifest - Parsed team.yaml manifest
158
272
  * @param {object} config - Plugin config (skilltree section)
273
+ * @param {object} [template] - Optional openteams ResolvedTemplate
159
274
  * @returns {Promise<object>} Map of roleName → { content, profile }
160
275
  */
161
- export async function compileAllRoleLoadouts(manifest, config) {
162
- const { defaults, roles: roleOverrides } = parseSkillTreeExtension(manifest);
276
+ export async function compileAllRoleLoadouts(manifest, config, template = null) {
163
277
  const allRoles = manifest.roles || [];
164
278
  const result = {};
165
279
 
166
280
  for (const roleName of allRoles) {
167
- // Merge defaults with role-specific overrides
168
- const roleCriteria = roleOverrides[roleName]
169
- ? { ...defaults, ...roleOverrides[roleName] }
170
- : { ...defaults };
171
-
172
- // Fallback chain for profile selection
173
- if (!roleCriteria.profile && !roleCriteria.tags && !roleCriteria.include && !roleCriteria.taskDescription) {
174
- if (config?.defaultProfile) {
175
- roleCriteria.profile = config.defaultProfile;
176
- } else {
177
- // Auto-infer from role name
178
- const inferred = inferProfileFromRole(roleName);
179
- if (inferred) {
180
- roleCriteria.profile = inferred;
181
- } else {
182
- continue; // No criteria at all — skip
183
- }
184
- }
185
- }
281
+ const roleCriteria = computeRoleCriteria(roleName, manifest, config, template);
282
+ if (!roleCriteria) continue;
186
283
 
187
284
  const loadout = await compileRoleLoadout(roleName, roleCriteria, config);
188
285
  if (loadout) {
@@ -195,3 +292,17 @@ export async function compileAllRoleLoadouts(manifest, config) {
195
292
 
196
293
  return result;
197
294
  }
295
+
296
+ function normalizeRolesMap(roles) {
297
+ if (!roles) return null;
298
+ if (roles instanceof Map) return roles;
299
+ if (typeof roles === "object") return new Map(Object.entries(roles));
300
+ return null;
301
+ }
302
+
303
+ function mergeUnique(a, b) {
304
+ const out = new Set();
305
+ for (const x of a ?? []) out.add(x);
306
+ for (const x of b ?? []) out.add(x);
307
+ return [...out];
308
+ }
package/src/template.mjs CHANGED
@@ -13,6 +13,12 @@ import { getGlobalNodeModules } from "./swarmkit-resolver.mjs";
13
13
  import { teamDir } from "./paths.mjs";
14
14
  import { writeRoles } from "./roles.mjs";
15
15
  import { createLogger } from "./log.mjs";
16
+ import { materializeLoadout } from "./loadout-materializer.mjs";
17
+ import {
18
+ checkMcpHealth,
19
+ collectScopeReferences,
20
+ discoverActiveSet,
21
+ } from "./mcp-health-checker.mjs";
16
22
 
17
23
  const log = createLogger("template");
18
24
  import { readConfig } from "./config.mjs";
@@ -151,7 +157,10 @@ export async function loadTeam(templateName) {
151
157
  // Use template name as fallback
152
158
  }
153
159
 
154
- // Compile skill-tree loadouts if enabled (cached alongside template artifacts)
160
+ // Compile skill-tree loadouts if enabled (cached alongside template artifacts).
161
+ // When openteams is available, we also pass the full ResolvedTemplate so
162
+ // compileAllRoleLoadouts can read `role.loadout.skills` (first-class) in
163
+ // addition to the legacy `skilltree:` extension.
155
164
  const config = readConfig();
156
165
  if (config.skilltree?.enabled) {
157
166
  const loadoutsPath = path.join(outputDir, "skill-loadouts.json");
@@ -159,7 +168,20 @@ export async function loadTeam(templateName) {
159
168
  try {
160
169
  const { compileAllRoleLoadouts } = await import("./skilltree-client.mjs");
161
170
  const manifest = readTeamManifest(templatePath);
162
- const loadouts = await compileAllRoleLoadouts(manifest, config.skilltree);
171
+ let template = null;
172
+ const ot = loadOpenteams();
173
+ if (ot?.TemplateLoader) {
174
+ try {
175
+ template = ot.TemplateLoader.load(templatePath);
176
+ } catch (err) {
177
+ log.warn("template load for skill-tree failed", { error: err.message });
178
+ }
179
+ }
180
+ const loadouts = await compileAllRoleLoadouts(
181
+ manifest,
182
+ config.skilltree,
183
+ template
184
+ );
163
185
  if (Object.keys(loadouts).length > 0) {
164
186
  fs.writeFileSync(loadoutsPath, JSON.stringify(loadouts, null, 2), "utf-8");
165
187
  }
@@ -169,9 +191,143 @@ export async function loadTeam(templateName) {
169
191
  }
170
192
  }
171
193
 
194
+ // Cache openteams loadout artifacts (per-role scope + providers + health report).
195
+ // Always regenerates on loadTeam — fresh active-set discovery each session.
196
+ try {
197
+ cacheLoadoutArtifacts({ templatePath, outputDir, templateName, teamName });
198
+ } catch (err) {
199
+ log.warn("loadout artifact caching failed", { error: err.message });
200
+ }
201
+
172
202
  return { success: true, templateName, templatePath, outputDir, teamName, cached };
173
203
  }
174
204
 
205
+ /**
206
+ * Materialize per-role loadout artifacts + team MCP state to the
207
+ * per-template cache directory. Side-effecting; only writes under
208
+ * `outputDir`. Best-effort — errors are logged but don't fail loadTeam.
209
+ *
210
+ * Written outputs (all under `outputDir`):
211
+ * loadouts/<role>.json — openteams LoadoutArtifacts (inspection + debug)
212
+ * scope/<role>.json — runtime scope file read by the scope-check hook
213
+ * mcp-providers.json — team.mcp_providers as a plain object (when non-empty)
214
+ * mcp-health.json — health report: ok / missing / refs / orphaned
215
+ */
216
+ export function cacheLoadoutArtifacts({
217
+ templatePath,
218
+ outputDir,
219
+ templateName,
220
+ teamName,
221
+ } = {}) {
222
+ const ot = loadOpenteams();
223
+ if (!ot?.TemplateLoader) return;
224
+
225
+ let template;
226
+ try {
227
+ template = ot.TemplateLoader.load(templatePath);
228
+ } catch (err) {
229
+ log.warn("template load failed during loadout caching", { error: err.message });
230
+ return;
231
+ }
232
+
233
+ const loadouts = template?.roles ?? new Map();
234
+ const providers = template?.mcpProviders ?? new Map();
235
+ const name = teamName || template?.manifest?.name || templateName;
236
+
237
+ // Per-role artifacts
238
+ const loadoutsDir = path.join(outputDir, "loadouts");
239
+ const scopeDir = path.join(outputDir, "scope");
240
+
241
+ // Short-circuit if there are no loadouts at all — avoid creating empty dirs.
242
+ const rolesWithLoadouts = [];
243
+ for (const [roleName, role] of loadouts) {
244
+ if (role?.loadout) rolesWithLoadouts.push([roleName, role]);
245
+ }
246
+
247
+ if (rolesWithLoadouts.length > 0) {
248
+ fs.mkdirSync(loadoutsDir, { recursive: true });
249
+ fs.mkdirSync(scopeDir, { recursive: true });
250
+
251
+ for (const [roleName, role] of rolesWithLoadouts) {
252
+ // Dump openteams' artifact view for debugging/observability.
253
+ if (ot.generateLoadoutArtifacts) {
254
+ try {
255
+ const artifacts = ot.generateLoadoutArtifacts(role.loadout);
256
+ fs.writeFileSync(
257
+ path.join(loadoutsDir, `${roleName}.json`),
258
+ JSON.stringify(artifacts, null, 2),
259
+ "utf-8"
260
+ );
261
+ } catch (err) {
262
+ log.warn("generateLoadoutArtifacts failed", {
263
+ role: roleName,
264
+ error: err.message,
265
+ });
266
+ }
267
+ }
268
+
269
+ // Scope file — consumed at runtime by scripts/scope-check.mjs
270
+ const scopeFilePath = path.join(scopeDir, `${roleName}.json`);
271
+ try {
272
+ const { scopeFile, warnings } = materializeLoadout({
273
+ role: {
274
+ name: roleName,
275
+ description: role.description,
276
+ displayName: role.displayName,
277
+ },
278
+ loadout: role.loadout,
279
+ template,
280
+ options: { teamName: name, scopeFilePath },
281
+ });
282
+ fs.writeFileSync(
283
+ scopeFilePath,
284
+ JSON.stringify(scopeFile, null, 2),
285
+ "utf-8"
286
+ );
287
+ for (const w of warnings) log.warn(w);
288
+ } catch (err) {
289
+ log.warn("scope-file materialization failed", {
290
+ role: roleName,
291
+ error: err.message,
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ // Team-level providers map
298
+ const providersObj =
299
+ providers instanceof Map ? Object.fromEntries(providers) : providers || {};
300
+ if (Object.keys(providersObj).length > 0) {
301
+ fs.writeFileSync(
302
+ path.join(outputDir, "mcp-providers.json"),
303
+ JSON.stringify(providersObj, null, 2),
304
+ "utf-8"
305
+ );
306
+ }
307
+
308
+ // Health report — regenerated each session against current active set
309
+ try {
310
+ const pluginPath = process.env.CLAUDE_PLUGIN_ROOT;
311
+ const activeSet = discoverActiveSet({
312
+ projectPath: process.cwd(),
313
+ pluginPath,
314
+ });
315
+ const scopeReferences = collectScopeReferences(template.loadouts ?? new Map());
316
+ const report = checkMcpHealth({
317
+ providers,
318
+ activeSet,
319
+ scopeReferences,
320
+ });
321
+ fs.writeFileSync(
322
+ path.join(outputDir, "mcp-health.json"),
323
+ JSON.stringify(report, null, 2),
324
+ "utf-8"
325
+ );
326
+ } catch (err) {
327
+ log.warn("mcp health check failed", { error: err.message });
328
+ }
329
+ }
330
+
175
331
  /**
176
332
  * Generate team artifacts (SKILL.md + agent prompts) using openteams.
177
333
  * Prefers programmatic API (generateSkillMd + generateAgentPrompts),