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.
- 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-hook.mjs +30 -5
- package/scripts/map-sidecar.mjs +32 -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 +176 -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__/sidecar-nudge.test.mjs +137 -0
- package/src/__tests__/skilltree-client.test.mjs +185 -1
- package/src/agent-generator.mjs +135 -8
- package/src/bootstrap.mjs +17 -9
- package/src/context-output.mjs +32 -0
- package/src/loadout-materializer.mjs +315 -0
- package/src/map-events.mjs +8 -1
- package/src/mcp-health-checker.mjs +237 -0
- package/src/sidecar-server.mjs +36 -0
- package/src/skilltree-client.mjs +135 -24
- 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 {
|
|
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/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:
|
|
253
|
-
name:
|
|
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
|
|
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:
|
|
323
|
-
name:
|
|
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(() => {})
|
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(
|