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.
Files changed (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. 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