chapterhouse 0.1.1
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/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
function createFakeClient(state) {
|
|
4
|
+
class FakeSession {
|
|
5
|
+
sessionId = "session-123";
|
|
6
|
+
listeners = new Map();
|
|
7
|
+
on(eventName, handler) {
|
|
8
|
+
const handlers = this.listeners.get(eventName) || [];
|
|
9
|
+
handlers.push(handler);
|
|
10
|
+
this.listeners.set(eventName, handlers);
|
|
11
|
+
return () => {
|
|
12
|
+
const next = (this.listeners.get(eventName) || []).filter((candidate) => candidate !== handler);
|
|
13
|
+
this.listeners.set(eventName, next);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async sendAndWait(request, _timeoutMs) {
|
|
17
|
+
state.sessionPrompts.push(request);
|
|
18
|
+
if (state.sendResult === "__PENDING__") {
|
|
19
|
+
return await new Promise((_resolve, reject) => {
|
|
20
|
+
state.pendingReject = reject;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return { data: { content: state.sendResult } };
|
|
24
|
+
}
|
|
25
|
+
async setModel(model) {
|
|
26
|
+
state.setModelCalls.push(model);
|
|
27
|
+
}
|
|
28
|
+
async abort() {
|
|
29
|
+
state.abortCalls++;
|
|
30
|
+
state.pendingReject?.(new Error("Aborted"));
|
|
31
|
+
}
|
|
32
|
+
async disconnect() {
|
|
33
|
+
state.disconnectCalls++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
getState() {
|
|
38
|
+
return "connected";
|
|
39
|
+
},
|
|
40
|
+
async listModels() {
|
|
41
|
+
return [{ id: "fallback-model" }, { id: "claude-sonnet-4.6" }];
|
|
42
|
+
},
|
|
43
|
+
async createSession(options) {
|
|
44
|
+
state.createSessionCalls.push(options);
|
|
45
|
+
if (state.createSessionError) {
|
|
46
|
+
throw new Error(state.createSessionError);
|
|
47
|
+
}
|
|
48
|
+
return new FakeSession();
|
|
49
|
+
},
|
|
50
|
+
async resumeSession(savedId, options) {
|
|
51
|
+
state.resumeSessionCalls.push({ savedId, options });
|
|
52
|
+
throw new Error("resume failed");
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function loadOrchestratorModule(t, overrides = {}) {
|
|
57
|
+
const state = {
|
|
58
|
+
store: new Map(),
|
|
59
|
+
config: {
|
|
60
|
+
copilotModel: "missing-model",
|
|
61
|
+
selfEditEnabled: true,
|
|
62
|
+
squadEnabled: false,
|
|
63
|
+
},
|
|
64
|
+
routeResults: [],
|
|
65
|
+
routerArgs: [],
|
|
66
|
+
createSessionCalls: [],
|
|
67
|
+
resumeSessionCalls: [],
|
|
68
|
+
sessionPrompts: [],
|
|
69
|
+
setModelCalls: [],
|
|
70
|
+
abortCalls: 0,
|
|
71
|
+
disconnectCalls: 0,
|
|
72
|
+
conversationLogs: [],
|
|
73
|
+
dbLogs: [],
|
|
74
|
+
episodeWrites: 0,
|
|
75
|
+
ensureDefaultAgentsCalls: 0,
|
|
76
|
+
loadAgentsCalls: 0,
|
|
77
|
+
setActiveAgentCalls: [],
|
|
78
|
+
setChannelProjectCalls: [],
|
|
79
|
+
channelProjects: new Map(),
|
|
80
|
+
parseMentionArgs: [],
|
|
81
|
+
buildAgentRosterArgs: [],
|
|
82
|
+
clearActiveTasksCalls: 0,
|
|
83
|
+
activeTasks: [
|
|
84
|
+
{ taskId: "task-1", agentSlug: "coder", status: "running", description: "Fix flaky tests" },
|
|
85
|
+
{ taskId: "task-2", agentSlug: "designer", status: "done", description: "Ignore me" },
|
|
86
|
+
],
|
|
87
|
+
registry: [
|
|
88
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
89
|
+
],
|
|
90
|
+
sendResult: "Finished successfully",
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
const client = createFakeClient(state);
|
|
94
|
+
t.mock.module("@github/copilot-sdk", {
|
|
95
|
+
namedExports: { approveAll: "approve-all" },
|
|
96
|
+
});
|
|
97
|
+
t.mock.module("./tools.js", {
|
|
98
|
+
namedExports: {
|
|
99
|
+
createTools: (deps) => {
|
|
100
|
+
state.toolDeps = deps;
|
|
101
|
+
return [{ name: "fake_tool", handler: async () => "ok" }];
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
t.mock.module("./system-message.js", {
|
|
106
|
+
namedExports: {
|
|
107
|
+
getOrchestratorSystemMessage: (options) => {
|
|
108
|
+
state.systemOptions = options;
|
|
109
|
+
return "SYSTEM";
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
t.mock.module("../config.js", {
|
|
114
|
+
namedExports: {
|
|
115
|
+
config: state.config,
|
|
116
|
+
DEFAULT_MODEL: "fallback-model",
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
t.mock.module("./mcp-config.js", {
|
|
120
|
+
namedExports: {
|
|
121
|
+
loadMcpConfig: () => ({ filesystem: { command: "filesystem" } }),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
t.mock.module("./skills.js", {
|
|
125
|
+
namedExports: {
|
|
126
|
+
getSkillDirectories: () => ["/skills/team"],
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
t.mock.module("./client.js", {
|
|
130
|
+
namedExports: {
|
|
131
|
+
resetClient: async () => client,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
t.mock.module("../squad/context.js", {
|
|
135
|
+
namedExports: {
|
|
136
|
+
setChannelProject: (channelKey, projectRoot) => {
|
|
137
|
+
state.setChannelProjectCalls.push({ channelKey, projectRoot });
|
|
138
|
+
state.channelProjects.set(channelKey, projectRoot);
|
|
139
|
+
},
|
|
140
|
+
getChannelProject: (channelKey) => state.channelProjects.get(channelKey),
|
|
141
|
+
normalizeProjectPath: (projectPath) => `normalized:${projectPath}`,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
t.mock.module("../squad/charter.js", {
|
|
145
|
+
namedExports: {
|
|
146
|
+
buildSquadSystemPrefix: async (_projectRoot) => "# Squad context\n",
|
|
147
|
+
getSquadCoordinatorSystemMessage: async (_projectRoot) => "# Squad Coordinator\nYou are the Squad coordinator.\n",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
t.mock.module("../store/db.js", {
|
|
151
|
+
namedExports: {
|
|
152
|
+
logConversation: (role, content, source) => {
|
|
153
|
+
state.dbLogs.push({ role, content, source });
|
|
154
|
+
},
|
|
155
|
+
getState: (key) => state.store.get(key),
|
|
156
|
+
setState: (key, value) => {
|
|
157
|
+
state.store.set(key, value);
|
|
158
|
+
},
|
|
159
|
+
deleteState: (key) => {
|
|
160
|
+
state.store.delete(key);
|
|
161
|
+
},
|
|
162
|
+
getCopilotSession: (_sessionKey) => undefined,
|
|
163
|
+
upsertCopilotSession: (_sessionKey, _mode, _copilotSessionId, _projectRoot, _model) => { },
|
|
164
|
+
getTaskSessionKey: (taskId) => state.taskSessionKeys?.get(taskId) ?? "default",
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
t.mock.module("./episode-writer.js", {
|
|
168
|
+
namedExports: {
|
|
169
|
+
maybeWriteEpisode: async () => {
|
|
170
|
+
state.episodeWrites++;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
t.mock.module("../wiki/context.js", {
|
|
175
|
+
namedExports: {
|
|
176
|
+
getWikiSummary: () => "wiki summary",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
t.mock.module("../paths.js", {
|
|
180
|
+
namedExports: {
|
|
181
|
+
SESSIONS_DIR: "/sessions",
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
t.mock.module("./router.js", {
|
|
185
|
+
namedExports: {
|
|
186
|
+
resolveModel: async (prompt, currentModel, recentTiers) => {
|
|
187
|
+
state.routerArgs.push([prompt, currentModel, [...recentTiers]]);
|
|
188
|
+
return state.routeResults.shift() || {
|
|
189
|
+
model: currentModel,
|
|
190
|
+
tier: null,
|
|
191
|
+
switched: false,
|
|
192
|
+
routerMode: "manual",
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
t.mock.module("./agents.js", {
|
|
198
|
+
namedExports: {
|
|
199
|
+
loadAgents: () => {
|
|
200
|
+
state.loadAgentsCalls++;
|
|
201
|
+
return [
|
|
202
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
203
|
+
{ slug: "designer", name: "Wash", model: "claude-opus-4.6" },
|
|
204
|
+
];
|
|
205
|
+
},
|
|
206
|
+
ensureDefaultAgents: () => {
|
|
207
|
+
state.ensureDefaultAgentsCalls++;
|
|
208
|
+
},
|
|
209
|
+
clearActiveTasks: async () => {
|
|
210
|
+
state.clearActiveTasksCalls++;
|
|
211
|
+
},
|
|
212
|
+
getAgentRegistry: () => state.registry,
|
|
213
|
+
getActiveAgent: () => undefined,
|
|
214
|
+
setActiveAgent: (channelKey, agentSlug) => {
|
|
215
|
+
state.setActiveAgentCalls.push({ channelKey, agentSlug });
|
|
216
|
+
},
|
|
217
|
+
parseAtMention: (text, projectRoot) => {
|
|
218
|
+
state.parseMentionArgs.push({ text, projectRoot });
|
|
219
|
+
return state.parseMentionResult;
|
|
220
|
+
},
|
|
221
|
+
buildAgentRoster: (projectRoot) => {
|
|
222
|
+
state.buildAgentRosterArgs.push(projectRoot);
|
|
223
|
+
return "@coder @designer";
|
|
224
|
+
},
|
|
225
|
+
getActiveTasks: () => state.activeTasks,
|
|
226
|
+
completeTask: () => { },
|
|
227
|
+
failTask: () => { },
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
t.mock.method(global, "setInterval", (_callback, delayMs) => {
|
|
231
|
+
state.healthCheckIntervalMs = delayMs;
|
|
232
|
+
return { ref() { }, unref() { } };
|
|
233
|
+
});
|
|
234
|
+
const orchestrator = await import(new URL(`./orchestrator.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
235
|
+
return { orchestrator, state, client };
|
|
236
|
+
}
|
|
237
|
+
test("initOrchestrator falls back to an available model and eagerly creates a session", async (t) => {
|
|
238
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t);
|
|
239
|
+
await orchestrator.initOrchestrator(client);
|
|
240
|
+
assert.equal(state.config.copilotModel, "fallback-model");
|
|
241
|
+
assert.equal(state.ensureDefaultAgentsCalls, 1);
|
|
242
|
+
assert.equal(state.loadAgentsCalls, 1);
|
|
243
|
+
assert.equal(state.createSessionCalls.length, 1);
|
|
244
|
+
assert.equal(state.healthCheckIntervalMs, 30_000);
|
|
245
|
+
assert.equal(state.systemOptions?.memorySummary, "wiki summary");
|
|
246
|
+
assert.equal(state.store.get("orchestrator_session_id"), "session-123");
|
|
247
|
+
});
|
|
248
|
+
test("sendToOrchestrator logs both sides, remembers web auth context, and records routing state", async (t) => {
|
|
249
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
250
|
+
config: {
|
|
251
|
+
copilotModel: "claude-sonnet-4.6",
|
|
252
|
+
selfEditEnabled: true,
|
|
253
|
+
squadEnabled: false,
|
|
254
|
+
},
|
|
255
|
+
routeResults: [
|
|
256
|
+
{
|
|
257
|
+
model: "claude-sonnet-4.6",
|
|
258
|
+
tier: "standard",
|
|
259
|
+
switched: false,
|
|
260
|
+
routerMode: "auto",
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
sendResult: "All green",
|
|
264
|
+
});
|
|
265
|
+
await orchestrator.initOrchestrator(client);
|
|
266
|
+
orchestrator.setMessageLogger((direction, source, text) => {
|
|
267
|
+
state.conversationLogs.push({ direction, source, text });
|
|
268
|
+
});
|
|
269
|
+
const user = {
|
|
270
|
+
id: "u-1",
|
|
271
|
+
name: "Ada Lovelace",
|
|
272
|
+
email: "ada@example.com",
|
|
273
|
+
role: "engineer",
|
|
274
|
+
};
|
|
275
|
+
const final = await new Promise((resolve) => {
|
|
276
|
+
orchestrator.sendToOrchestrator("Summarize the deployment", { type: "web", connectionId: "conn-1", user, authorizationHeader: " Bearer token-123 " }, (text, done) => {
|
|
277
|
+
if (done) {
|
|
278
|
+
resolve(text);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
assert.equal(final, "All green");
|
|
283
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] Summarize the deployment" }]);
|
|
284
|
+
assert.deepEqual(state.routerArgs, [["[via web] Summarize the deployment", "claude-sonnet-4.6", []]]);
|
|
285
|
+
assert.deepEqual(orchestrator.getCurrentAuthenticatedUser(), user);
|
|
286
|
+
assert.equal(orchestrator.getCurrentAuthorizationHeader(), "Bearer token-123");
|
|
287
|
+
assert.deepEqual(orchestrator.getLastAuthenticatedUser(), user);
|
|
288
|
+
assert.deepEqual(orchestrator.getLastRouteResult(), {
|
|
289
|
+
model: "claude-sonnet-4.6",
|
|
290
|
+
tier: "standard",
|
|
291
|
+
switched: false,
|
|
292
|
+
routerMode: "auto",
|
|
293
|
+
});
|
|
294
|
+
assert.deepEqual(state.conversationLogs, [
|
|
295
|
+
{ direction: "in", source: "web", text: "Summarize the deployment" },
|
|
296
|
+
{ direction: "out", source: "web", text: "All green" },
|
|
297
|
+
]);
|
|
298
|
+
assert.deepEqual(state.dbLogs, [
|
|
299
|
+
{ role: "user", content: "Summarize the deployment", source: "web" },
|
|
300
|
+
{ role: "assistant", content: "All green", source: "web" },
|
|
301
|
+
]);
|
|
302
|
+
assert.equal(state.episodeWrites, 1);
|
|
303
|
+
});
|
|
304
|
+
test("@mentions route through the orchestrator session without invoking the model router", async (t) => {
|
|
305
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
306
|
+
config: {
|
|
307
|
+
copilotModel: "claude-sonnet-4.6",
|
|
308
|
+
selfEditEnabled: true,
|
|
309
|
+
squadEnabled: false,
|
|
310
|
+
},
|
|
311
|
+
parseMentionResult: { agentSlug: "designer", message: "polish the landing page" },
|
|
312
|
+
sendResult: "Delegated",
|
|
313
|
+
});
|
|
314
|
+
await orchestrator.initOrchestrator(client);
|
|
315
|
+
const final = await new Promise((resolve) => {
|
|
316
|
+
orchestrator.sendToOrchestrator("@designer polish the landing page", { type: "web", connectionId: "conn-2" }, (text, done) => {
|
|
317
|
+
if (done) {
|
|
318
|
+
resolve(text);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
assert.equal(final, "Delegated");
|
|
323
|
+
assert.deepEqual(state.setActiveAgentCalls, [{ channelKey: "conn-2", agentSlug: "designer" }]);
|
|
324
|
+
assert.deepEqual(state.routerArgs, []);
|
|
325
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
|
|
326
|
+
});
|
|
327
|
+
test("web projectPath activates squad context, rebuilds the orchestrator roster, and routes mentions per connection", async (t) => {
|
|
328
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
329
|
+
config: {
|
|
330
|
+
copilotModel: "claude-sonnet-4.6",
|
|
331
|
+
selfEditEnabled: true,
|
|
332
|
+
squadEnabled: true,
|
|
333
|
+
},
|
|
334
|
+
parseMentionResult: { agentSlug: "ripley", message: "audit the squad routing" },
|
|
335
|
+
sendResult: "Squad handled it",
|
|
336
|
+
});
|
|
337
|
+
await orchestrator.initOrchestrator(client);
|
|
338
|
+
const final = await new Promise((resolve) => {
|
|
339
|
+
orchestrator.sendToOrchestrator("@ripley audit the squad routing", { type: "web", connectionId: "conn-squad", projectPath: "~/workspace/mock-squad" }, (text, done) => {
|
|
340
|
+
if (done) {
|
|
341
|
+
resolve(text);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
assert.equal(final, "Squad handled it");
|
|
346
|
+
assert.deepEqual(state.setChannelProjectCalls, [{
|
|
347
|
+
channelKey: "conn-squad",
|
|
348
|
+
projectRoot: "normalized:~/workspace/mock-squad",
|
|
349
|
+
}]);
|
|
350
|
+
assert.deepEqual(state.parseMentionArgs.at(-1), {
|
|
351
|
+
text: "@ripley audit the squad routing",
|
|
352
|
+
projectRoot: "normalized:~/workspace/mock-squad",
|
|
353
|
+
});
|
|
354
|
+
assert.deepEqual(state.setActiveAgentCalls, [{ channelKey: "conn-squad", agentSlug: "ripley" }]);
|
|
355
|
+
assert.deepEqual(state.sessionPrompts.at(-1), [{ prompt: "[via web] audit the squad routing" }][0]);
|
|
356
|
+
// Project sessions use getSquadCoordinatorSystemMessage (not buildAgentRoster) — only the default
|
|
357
|
+
// session init triggers a buildAgentRoster call.
|
|
358
|
+
assert.deepEqual(state.buildAgentRosterArgs, [undefined]);
|
|
359
|
+
assert.equal(state.createSessionCalls.length, 2);
|
|
360
|
+
assert.equal(typeof orchestrator.getCurrentChannelKey, "function");
|
|
361
|
+
assert.equal(orchestrator.getCurrentChannelKey(), undefined);
|
|
362
|
+
});
|
|
363
|
+
test("feedAgentResult injects a background completion turn and proactively notifies listeners", async (t) => {
|
|
364
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
365
|
+
config: {
|
|
366
|
+
copilotModel: "claude-sonnet-4.6",
|
|
367
|
+
selfEditEnabled: true,
|
|
368
|
+
squadEnabled: false,
|
|
369
|
+
},
|
|
370
|
+
sendResult: "Agent complete",
|
|
371
|
+
});
|
|
372
|
+
await orchestrator.initOrchestrator(client);
|
|
373
|
+
const notified = new Promise((resolve) => {
|
|
374
|
+
orchestrator.setProactiveNotify(resolve);
|
|
375
|
+
});
|
|
376
|
+
orchestrator.feedAgentResult("task-9", "coder", "Fixed the flaky test");
|
|
377
|
+
assert.equal(await notified, "Agent complete");
|
|
378
|
+
assert.deepEqual(state.sessionPrompts, [{
|
|
379
|
+
prompt: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
|
|
380
|
+
}]);
|
|
381
|
+
assert.deepEqual(state.dbLogs, [
|
|
382
|
+
{
|
|
383
|
+
role: "system",
|
|
384
|
+
content: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
|
|
385
|
+
source: "background",
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
role: "assistant",
|
|
389
|
+
content: "Agent complete",
|
|
390
|
+
source: "background",
|
|
391
|
+
},
|
|
392
|
+
]);
|
|
393
|
+
});
|
|
394
|
+
test("cancelCurrentMessage aborts the active request and agent helpers expose running work", async (t) => {
|
|
395
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
396
|
+
config: {
|
|
397
|
+
copilotModel: "claude-sonnet-4.6",
|
|
398
|
+
selfEditEnabled: true,
|
|
399
|
+
squadEnabled: false,
|
|
400
|
+
},
|
|
401
|
+
sendResult: "__PENDING__",
|
|
402
|
+
});
|
|
403
|
+
await orchestrator.initOrchestrator(client);
|
|
404
|
+
orchestrator.sendToOrchestrator("first request", { type: "background" }, () => { });
|
|
405
|
+
orchestrator.sendToOrchestrator("second request", { type: "background" }, () => { });
|
|
406
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
407
|
+
assert.equal(await orchestrator.cancelCurrentMessage(), true);
|
|
408
|
+
assert.equal(state.abortCalls, 1);
|
|
409
|
+
assert.deepEqual(orchestrator.getAgentInfo(), [
|
|
410
|
+
{
|
|
411
|
+
slug: "coder",
|
|
412
|
+
name: "Kaylee",
|
|
413
|
+
model: "claude-sonnet-4.6",
|
|
414
|
+
taskId: "task-1",
|
|
415
|
+
description: "Fix flaky tests",
|
|
416
|
+
},
|
|
417
|
+
]);
|
|
418
|
+
await orchestrator.shutdownAgents();
|
|
419
|
+
assert.equal(state.clearActiveTasksCalls, 1);
|
|
420
|
+
});
|
|
421
|
+
test("feedAgentResult routes to project session when task session_key is a project key", async (t) => {
|
|
422
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
423
|
+
config: {
|
|
424
|
+
copilotModel: "claude-sonnet-4.6",
|
|
425
|
+
selfEditEnabled: true,
|
|
426
|
+
squadEnabled: false,
|
|
427
|
+
},
|
|
428
|
+
sendResult: "Routed correctly",
|
|
429
|
+
taskSessionKeys: new Map([["proj-task-1", "project:/repo/squad"]]),
|
|
430
|
+
});
|
|
431
|
+
await orchestrator.initOrchestrator(client);
|
|
432
|
+
// initOrchestrator creates the default session — record that baseline
|
|
433
|
+
const sessionsAfterInit = state.createSessionCalls.length;
|
|
434
|
+
const notified = new Promise((resolve) => {
|
|
435
|
+
orchestrator.setProactiveNotify(resolve);
|
|
436
|
+
});
|
|
437
|
+
// Task belongs to the project session; feedAgentResult must open that session
|
|
438
|
+
orchestrator.feedAgentResult("proj-task-1", "coder", "Squad feature done");
|
|
439
|
+
assert.equal(await notified, "Routed correctly");
|
|
440
|
+
// A second createSession call proves the orchestrator opened a fresh project session
|
|
441
|
+
// rather than reusing the already-open default session.
|
|
442
|
+
assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a project session, not recycle the default one");
|
|
443
|
+
// The prompt must reference the task and agent
|
|
444
|
+
const prompt = state.sessionPrompts.at(-1);
|
|
445
|
+
assert.ok(prompt?.prompt.includes("proj-task-1"), "prompt should reference the task id");
|
|
446
|
+
assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
|
|
447
|
+
assert.ok(prompt?.prompt.includes("Squad feature done"), "prompt should include the result text");
|
|
448
|
+
});
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Sprint 3, Item 5 — orchestrator lifecycle: project-session cancel/shutdown
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
test("cancelCurrentMessage targets the active project session, not the default session", async (t) => {
|
|
453
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
454
|
+
config: {
|
|
455
|
+
copilotModel: "claude-sonnet-4.6",
|
|
456
|
+
selfEditEnabled: false,
|
|
457
|
+
// squadEnabled true so sendToOrchestrator derives a "project:…" session key
|
|
458
|
+
squadEnabled: true,
|
|
459
|
+
},
|
|
460
|
+
sendResult: "__PENDING__",
|
|
461
|
+
});
|
|
462
|
+
await orchestrator.initOrchestrator(client);
|
|
463
|
+
// Default session was created during init
|
|
464
|
+
const sessionsAfterInit = state.createSessionCalls.length;
|
|
465
|
+
// Send to a project path — this should open a separate project session
|
|
466
|
+
orchestrator.sendToOrchestrator("scaffold the new API", { type: "web", connectionId: "conn-project", projectPath: "/repo/squad-proj" }, () => { });
|
|
467
|
+
// Yield to the event loop so the project session is created and the
|
|
468
|
+
// sendAndWait promise is parked in PENDING state.
|
|
469
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
470
|
+
assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "a distinct project session must be created, separate from the default session");
|
|
471
|
+
// Cancel should abort the active project session, not the default one
|
|
472
|
+
const cancelled = await orchestrator.cancelCurrentMessage();
|
|
473
|
+
assert.equal(cancelled, true, "cancelCurrentMessage should return true when an active request was aborted");
|
|
474
|
+
assert.equal(state.abortCalls, 1, "exactly one abort — on the in-flight project session");
|
|
475
|
+
});
|
|
476
|
+
test("shutdownAgents disconnects all sessions, clears maps, and clears active tasks", async (t) => {
|
|
477
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
478
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: true },
|
|
479
|
+
sendResult: "ok",
|
|
480
|
+
});
|
|
481
|
+
// Init default session
|
|
482
|
+
await orchestrator.initOrchestrator(client);
|
|
483
|
+
const sessionsAfterInit = state.createSessionCalls.length;
|
|
484
|
+
// Trigger a project session by sending a message with a projectPath
|
|
485
|
+
orchestrator.sendToOrchestrator("hello from project", { type: "web", connectionId: "conn-1", projectPath: "/fake/project" }, (_text, _done) => { });
|
|
486
|
+
// Yield so the async session-creation IIFE runs
|
|
487
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
488
|
+
assert.ok(state.createSessionCalls.length > sessionsAfterInit, "a project session should have been created");
|
|
489
|
+
// Shutdown — both sessions should be disconnected
|
|
490
|
+
await orchestrator.shutdownAgents();
|
|
491
|
+
assert.equal(state.disconnectCalls, 2, "disconnect must be called once per session (default + project)");
|
|
492
|
+
assert.equal(state.clearActiveTasksCalls, 1, "clearActiveTasks must be called exactly once");
|
|
493
|
+
// Re-init after shutdown must create a fresh session (proves sessionMap was cleared)
|
|
494
|
+
await orchestrator.initOrchestrator(client);
|
|
495
|
+
assert.ok(state.createSessionCalls.length > sessionsAfterInit + 1, "re-init after shutdown must create a new session (not reuse stale sessionMap entry)");
|
|
496
|
+
});
|
|
497
|
+
test("ensureOrchestratorSession cleans up in-flight promise on session creation failure", async (t) => {
|
|
498
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
499
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: false, squadEnabled: false },
|
|
500
|
+
// Non-recoverable error so the retry loop exits immediately
|
|
501
|
+
createSessionError: "fatal: SDK host permanently unavailable",
|
|
502
|
+
sendResult: "unreachable",
|
|
503
|
+
});
|
|
504
|
+
// initOrchestrator catches the creation failure silently
|
|
505
|
+
await orchestrator.initOrchestrator(client);
|
|
506
|
+
// First message — session creation will fail
|
|
507
|
+
const received = [];
|
|
508
|
+
orchestrator.sendToOrchestrator("hello", { type: "background" }, (text, done) => {
|
|
509
|
+
received.push({ text, done });
|
|
510
|
+
});
|
|
511
|
+
// Yield long enough for the async IIFE inside sendToOrchestrator to settle
|
|
512
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
513
|
+
assert.ok(received.length > 0, "callback must be invoked after creation failure");
|
|
514
|
+
assert.ok(received[0].text.startsWith("Error:"), `callback should deliver an Error message; got: "${received[0].text}"`);
|
|
515
|
+
assert.equal(received[0].done, true, "done flag must be true on error delivery");
|
|
516
|
+
// The in-flight promise must be cleaned up (sessionCreatePromises.delete ran in finally).
|
|
517
|
+
// A second call must attempt a fresh createSession — proving the promise map was cleared.
|
|
518
|
+
const countAfterFirst = state.createSessionCalls.length;
|
|
519
|
+
orchestrator.sendToOrchestrator("retry", { type: "background" }, () => { });
|
|
520
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
521
|
+
assert.ok(state.createSessionCalls.length > countAfterFirst, "a second message must trigger a new createSession attempt, proving sessionCreatePromises was cleaned up");
|
|
522
|
+
});
|
|
523
|
+
//# sourceMappingURL=orchestrator.test.js.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { getState, setState } from "../store/db.js";
|
|
2
|
+
import { classifyWithLLM } from "./classifier.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Default configuration
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
const DEFAULT_CONFIG = {
|
|
7
|
+
enabled: false,
|
|
8
|
+
tierModels: {
|
|
9
|
+
fast: "gpt-4.1",
|
|
10
|
+
standard: "claude-sonnet-4.6",
|
|
11
|
+
premium: "claude-opus-4.6",
|
|
12
|
+
},
|
|
13
|
+
overrides: [
|
|
14
|
+
{
|
|
15
|
+
name: "design",
|
|
16
|
+
keywords: [
|
|
17
|
+
"design", "ui", "ux", "css", "layout", "styling", "visual",
|
|
18
|
+
"mockup", "wireframe", "frontend design", "tailwind", "responsive",
|
|
19
|
+
],
|
|
20
|
+
model: "claude-opus-4.6",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
cooldownMessages: 2,
|
|
24
|
+
};
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Module-level state
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
let messagesSinceSwitch = Infinity;
|
|
29
|
+
// Short replies that should inherit the previous turn's tier
|
|
30
|
+
const FOLLOW_UP_PATTERNS = [
|
|
31
|
+
"yes", "no", "do it", "go ahead", "sure", "sounds good", "looks good",
|
|
32
|
+
"perfect", "+1", "please", "yep", "yup", "nope", "nah", "ok", "okay",
|
|
33
|
+
"got it", "cool", "nice", "great", "alright", "right",
|
|
34
|
+
];
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
/** Strip channel prefixes and trim whitespace. */
|
|
39
|
+
function sanitize(prompt) {
|
|
40
|
+
return prompt.replace(/^\[via [a-z]+\]\s*/i, "").trim();
|
|
41
|
+
}
|
|
42
|
+
/** Word-boundary match that avoids partial-word hits (e.g. "ui" ≠ "fruit"). */
|
|
43
|
+
function wordMatch(text, keyword) {
|
|
44
|
+
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
return new RegExp(`\\b${escaped}\\b`, "i").test(text);
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Config management
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
export function getRouterConfig() {
|
|
51
|
+
const stored = getState("router_config");
|
|
52
|
+
if (stored) {
|
|
53
|
+
try {
|
|
54
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(stored) };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { ...DEFAULT_CONFIG };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { ...DEFAULT_CONFIG };
|
|
61
|
+
}
|
|
62
|
+
export function updateRouterConfig(partial) {
|
|
63
|
+
const current = getRouterConfig();
|
|
64
|
+
const merged = {
|
|
65
|
+
...current,
|
|
66
|
+
...partial,
|
|
67
|
+
tierModels: {
|
|
68
|
+
...current.tierModels,
|
|
69
|
+
...(partial.tierModels ?? {}),
|
|
70
|
+
},
|
|
71
|
+
overrides: partial.overrides ?? current.overrides,
|
|
72
|
+
};
|
|
73
|
+
setState("router_config", JSON.stringify(merged));
|
|
74
|
+
return merged;
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Classification
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* Classify a message using GPT-4.1. Falls back to "standard" if the LLM
|
|
81
|
+
* is unavailable. Background tasks and follow-ups are handled deterministically.
|
|
82
|
+
*/
|
|
83
|
+
async function classifyMessage(prompt, recentTiers, client) {
|
|
84
|
+
const text = sanitize(prompt);
|
|
85
|
+
const lower = text.toLowerCase();
|
|
86
|
+
// Background tasks → always standard
|
|
87
|
+
if (lower.startsWith("[background task completed]"))
|
|
88
|
+
return "standard";
|
|
89
|
+
// Short follow-ups inherit the previous tier
|
|
90
|
+
if (text.length < 20 && recentTiers.length > 0) {
|
|
91
|
+
const isFollowUp = FOLLOW_UP_PATTERNS.some((p) => lower === p || lower === p + ".");
|
|
92
|
+
if (isFollowUp)
|
|
93
|
+
return recentTiers[recentTiers.length - 1];
|
|
94
|
+
}
|
|
95
|
+
// LLM classification
|
|
96
|
+
if (client) {
|
|
97
|
+
const tier = await classifyWithLLM(client, text);
|
|
98
|
+
if (tier) {
|
|
99
|
+
console.log(`[chapterhouse] Classifier: ${tier}`);
|
|
100
|
+
return tier;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Fallback — standard is always safe
|
|
104
|
+
console.log(`[chapterhouse] Classifier (fallback): standard`);
|
|
105
|
+
return "standard";
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Main entry point
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
export async function resolveModel(prompt, currentModel, recentTiers, client) {
|
|
111
|
+
const config = getRouterConfig();
|
|
112
|
+
// Router disabled → manual mode
|
|
113
|
+
if (!config.enabled) {
|
|
114
|
+
messagesSinceSwitch = Infinity;
|
|
115
|
+
return { model: currentModel, tier: null, switched: false, routerMode: "manual" };
|
|
116
|
+
}
|
|
117
|
+
const text = sanitize(prompt);
|
|
118
|
+
// 1. Check overrides first — they bypass cooldown
|
|
119
|
+
for (const rule of config.overrides) {
|
|
120
|
+
if (rule.keywords.some((kw) => wordMatch(text, kw))) {
|
|
121
|
+
const switched = rule.model !== currentModel;
|
|
122
|
+
if (switched)
|
|
123
|
+
messagesSinceSwitch = 0;
|
|
124
|
+
return { model: rule.model, tier: null, overrideName: rule.name, switched, routerMode: "auto" };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// 2. Classify the message
|
|
128
|
+
const tier = await classifyMessage(prompt, recentTiers, client);
|
|
129
|
+
const targetModel = config.tierModels[tier];
|
|
130
|
+
const wouldSwitch = targetModel !== currentModel;
|
|
131
|
+
// 3. Cooldown — prevent rapid switching
|
|
132
|
+
if (wouldSwitch && messagesSinceSwitch < config.cooldownMessages) {
|
|
133
|
+
messagesSinceSwitch++;
|
|
134
|
+
return { model: currentModel, tier, switched: false, routerMode: "auto" };
|
|
135
|
+
}
|
|
136
|
+
if (wouldSwitch)
|
|
137
|
+
messagesSinceSwitch = 0;
|
|
138
|
+
else
|
|
139
|
+
messagesSinceSwitch++;
|
|
140
|
+
return { model: targetModel, tier, switched: wouldSwitch, routerMode: "auto" };
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=router.js.map
|