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,447 @@
1
+ import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, rmSync, copyFileSync } from "fs";
2
+ import { createHash } from "crypto";
3
+ import { join, dirname, sep } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { z } from "zod";
6
+ import { approveAll } from "@github/copilot-sdk";
7
+ import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
8
+ import { getState, setState } from "../store/db.js";
9
+ import { loadMcpConfig } from "./mcp-config.js";
10
+ import { getSkillDirectories } from "./skills.js";
11
+ import { findSquadAgent, renderProjectAgentRoster } from "../squad/registry.js";
12
+ // Frontmatter schema
13
+ const agentFrontmatterSchema = z.object({
14
+ name: z.string().min(1),
15
+ description: z.string().min(1),
16
+ model: z.string().min(1),
17
+ skills: z.array(z.string()).optional(),
18
+ tools: z.array(z.string()).optional(),
19
+ mcpServers: z.array(z.string()).optional(),
20
+ });
21
+ // ---------------------------------------------------------------------------
22
+ // Agent Registry
23
+ // ---------------------------------------------------------------------------
24
+ let agentRegistry = [];
25
+ /** Bundled agents shipped with the package */
26
+ const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
27
+ const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
28
+ const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
29
+ /** Parse YAML frontmatter and markdown body from an .agent.md file. */
30
+ export function parseAgentMd(content, slug) {
31
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\s*([\s\S]*)$/);
32
+ if (!fmMatch)
33
+ return null;
34
+ const frontmatterRaw = fmMatch[1];
35
+ const body = fmMatch[2].trim();
36
+ // Simple YAML parser for flat + array values
37
+ const parsed = {};
38
+ for (const line of frontmatterRaw.split("\n")) {
39
+ const idx = line.indexOf(": ");
40
+ if (idx <= 0)
41
+ continue;
42
+ const key = line.slice(0, idx).trim();
43
+ let value = line.slice(idx + 2).trim();
44
+ // Handle YAML quoted strings
45
+ if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
46
+ value = value.slice(1, -1);
47
+ }
48
+ parsed[key] = value;
49
+ }
50
+ // Parse arrays from YAML inline syntax: [a, b, c]
51
+ for (const key of ["skills", "tools", "mcpServers"]) {
52
+ const raw = parsed[key];
53
+ if (typeof raw === "string") {
54
+ const arrMatch = raw.match(/^\[(.*)\]$/);
55
+ if (arrMatch) {
56
+ parsed[key] = arrMatch[1]
57
+ .split(",")
58
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
59
+ .filter(Boolean);
60
+ }
61
+ }
62
+ }
63
+ const result = agentFrontmatterSchema.safeParse(parsed);
64
+ if (!result.success) {
65
+ console.warn(`[agents] Invalid frontmatter in ${slug}.agent.md:`, result.error.format());
66
+ return null;
67
+ }
68
+ const fm = result.data;
69
+ return {
70
+ slug,
71
+ name: fm.name,
72
+ description: fm.description,
73
+ model: fm.model,
74
+ skills: fm.skills,
75
+ tools: fm.tools,
76
+ mcpServers: fm.mcpServers,
77
+ systemMessage: body,
78
+ };
79
+ }
80
+ /** Scan ~/.chapterhouse/agents/ for .agent.md files and load configs. */
81
+ export function loadAgents() {
82
+ if (!existsSync(AGENTS_DIR)) {
83
+ mkdirSync(AGENTS_DIR, { recursive: true });
84
+ return [];
85
+ }
86
+ const configs = [];
87
+ let entries;
88
+ try {
89
+ entries = readdirSync(AGENTS_DIR);
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ for (const entry of entries) {
95
+ if (!entry.endsWith(".agent.md"))
96
+ continue;
97
+ const slug = entry.replace(/\.agent\.md$/, "");
98
+ try {
99
+ const content = readFileSync(join(AGENTS_DIR, entry), "utf-8");
100
+ const config = parseAgentMd(content, slug);
101
+ if (config)
102
+ configs.push(config);
103
+ }
104
+ catch (err) {
105
+ console.warn(`[agents] Failed to read ${entry}:`, err instanceof Error ? err.message : err);
106
+ }
107
+ }
108
+ agentRegistry = configs;
109
+ return configs;
110
+ }
111
+ /** Get agent config by name or slug (case-insensitive). */
112
+ export function getAgent(nameOrSlug) {
113
+ const lower = nameOrSlug.toLowerCase();
114
+ return agentRegistry.find((a) => a.slug === lower || a.name.toLowerCase() === lower);
115
+ }
116
+ /** Get all loaded agent configs. */
117
+ export function getAgentRegistry() {
118
+ return [...agentRegistry];
119
+ }
120
+ /** Copy bundled agents to ~/.chapterhouse/agents/, updating stale copies when the bundled version changes.
121
+ * Respects user customizations: if the user edited the deployed file after our last sync, we skip it. */
122
+ export function ensureDefaultAgents() {
123
+ mkdirSync(AGENTS_DIR, { recursive: true });
124
+ if (!existsSync(BUNDLED_AGENTS_DIR))
125
+ return;
126
+ let bundled;
127
+ try {
128
+ bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".agent.md"));
129
+ }
130
+ catch {
131
+ return;
132
+ }
133
+ for (const file of bundled) {
134
+ const src = join(BUNDLED_AGENTS_DIR, file);
135
+ const dest = join(AGENTS_DIR, file);
136
+ const srcHash = createHash("sha256").update(readFileSync(src)).digest("hex");
137
+ const stateKey = `bundled_agent_hash:${file}`;
138
+ if (!existsSync(dest)) {
139
+ copyFileSync(src, dest);
140
+ setState(stateKey, srcHash);
141
+ console.log(`[agents] Installed bundled agent: ${file}`);
142
+ continue;
143
+ }
144
+ // Check if the bundled version actually changed since our last sync
145
+ const lastSyncedHash = getState(stateKey);
146
+ if (lastSyncedHash === srcHash)
147
+ continue; // bundled hasn't changed
148
+ // Bundled version changed — only overwrite if the user hasn't customized it.
149
+ // If we have a record of what we last deployed, check if the file still matches.
150
+ const destHash = createHash("sha256").update(readFileSync(dest)).digest("hex");
151
+ if (lastSyncedHash && destHash !== lastSyncedHash) {
152
+ // User modified the file after our last sync — don't clobber their changes
153
+ console.log(`[agents] Skipping ${file} — user has local customizations`);
154
+ continue;
155
+ }
156
+ // Safe to update: either first sync (no record) or file is unmodified from our last deploy
157
+ copyFileSync(src, dest);
158
+ setState(stateKey, srcHash);
159
+ console.log(`[agents] Updated bundled agent: ${file}`);
160
+ }
161
+ }
162
+ /** Create a new agent .md file. Returns error string or null on success. */
163
+ export function createAgentFile(slug, name, description, model, systemPrompt, skills, tools) {
164
+ if (!SLUG_REGEX.test(slug)) {
165
+ return `Invalid slug '${slug}': must be kebab-case (a-z0-9 with hyphens).`;
166
+ }
167
+ const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
168
+ if (!filePath.startsWith(AGENTS_DIR + sep)) {
169
+ return `Invalid slug '${slug}': path traversal detected.`;
170
+ }
171
+ if (existsSync(filePath)) {
172
+ return `Agent '${slug}' already exists. Edit it directly or remove it first.`;
173
+ }
174
+ // YAML value escaping for safe frontmatter
175
+ const escapedName = name.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
176
+ const escapedDesc = description.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
177
+ let frontmatter = `---\nname: "${escapedName}"\ndescription: "${escapedDesc}"\nmodel: ${model}`;
178
+ if (skills?.length)
179
+ frontmatter += `\nskills:\n${skills.map((s) => ` - ${s}`).join("\n")}`;
180
+ if (tools?.length)
181
+ frontmatter += `\ntools:\n${tools.map((t) => ` - ${t}`).join("\n")}`;
182
+ frontmatter += "\n---\n\n";
183
+ writeFileSync(filePath, frontmatter + systemPrompt + "\n");
184
+ return null;
185
+ }
186
+ /** Remove an agent .md file. Returns error string or null on success. */
187
+ export function removeAgentFile(slug) {
188
+ if (!SLUG_REGEX.test(slug)) {
189
+ return `Invalid slug '${slug}'.`;
190
+ }
191
+ if (RESERVED_SLUGS.has(slug)) {
192
+ return `Cannot remove built-in agent '${slug}'. You can edit its file instead.`;
193
+ }
194
+ const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
195
+ if (!filePath.startsWith(AGENTS_DIR + sep)) {
196
+ return `Invalid slug '${slug}': path traversal detected.`;
197
+ }
198
+ if (!existsSync(filePath)) {
199
+ return `Agent '${slug}' not found.`;
200
+ }
201
+ rmSync(filePath);
202
+ return null;
203
+ }
204
+ // ---------------------------------------------------------------------------
205
+ // Agent Session Management
206
+ // ---------------------------------------------------------------------------
207
+ // Per-agent task tracking (in-memory, backed by DB)
208
+ const activeTasks = new Map();
209
+ let taskCounter = 0;
210
+ function nextTaskId() {
211
+ return `task-${++taskCounter}-${Date.now().toString(36)}`;
212
+ }
213
+ /** Shared base prompt injected into all agent sessions. */
214
+ function getAgentBasePrompt() {
215
+ return `## Runtime Context
216
+
217
+ You are an agent within Chapterhouse, a team-level AI assistant for engineering teams. You run on the user's local machine.
218
+
219
+ ### Shared Wiki
220
+ All agents share a wiki knowledge base for persistent memory. Use \`wiki_read\` and \`wiki_search\` to find existing knowledge, and \`wiki_update\` to save important findings.
221
+
222
+ ### Communication
223
+ - You receive tasks from @chapterhouse (the orchestrator) or directly from the user
224
+ - Your results are relayed back to the user by @chapterhouse
225
+ - To share knowledge with other agents, write to the wiki
226
+
227
+ ### Guidelines
228
+ - Be thorough but concise in your responses
229
+ - Use the wiki to check for existing context before starting work
230
+ - Save important findings to the wiki for other agents to use
231
+ `;
232
+ }
233
+ /** Build the full system message for an agent. */
234
+ export function composeAgentSystemMessage(agent, rosterInfo) {
235
+ const base = getAgentBasePrompt();
236
+ const agentPrompt = agent.systemMessage;
237
+ // For @chapterhouse, inject the agent roster
238
+ if (agent.slug === "chapterhouse" && rosterInfo) {
239
+ return agentPrompt.replace("{agent_roster}", rosterInfo);
240
+ }
241
+ return `${agentPrompt}\n\n${base}`;
242
+ }
243
+ /** Build a roster description of all agents for @chapterhouse's system prompt. */
244
+ export function buildAgentRoster(projectRoot) {
245
+ const agents = getAgentRegistry();
246
+ const chLines = agents
247
+ .filter((a) => a.slug !== "chapterhouse")
248
+ .map((a) => {
249
+ const model = a.model === "auto" ? "dynamic (you choose)" : a.model;
250
+ const skills = a.skills?.length ? ` | skills: ${a.skills.join(", ")}` : "";
251
+ return `- **@${a.slug}** — ${a.description} (model: ${model}${skills})`;
252
+ });
253
+ let squadLines = [];
254
+ if (projectRoot) {
255
+ try {
256
+ const squadRoster = renderProjectAgentRoster(projectRoot);
257
+ if (squadRoster) {
258
+ squadLines = squadRoster.split("\n").filter(Boolean);
259
+ }
260
+ }
261
+ catch {
262
+ // squad roster unavailable — skip
263
+ }
264
+ }
265
+ const allLines = [...chLines, ...squadLines];
266
+ if (allLines.length === 0)
267
+ return "No agents registered.";
268
+ return allLines.join("\n");
269
+ }
270
+ // The wiki tools that every agent gets regardless of tool config
271
+ const WIKI_TOOL_NAMES = new Set([
272
+ "wiki_search", "wiki_read", "wiki_update", "remember", "recall", "forget",
273
+ "wiki_ingest", "wiki_lint", "wiki_rebuild_index",
274
+ ]);
275
+ // Management tools that only @chapterhouse should have
276
+ const MANAGEMENT_TOOL_NAMES = new Set([
277
+ "delegate_to_agent", "check_agent_status", "get_agent_result",
278
+ "show_agent_roster", "hire_agent", "fire_agent",
279
+ "switch_model", "toggle_auto", "list_models",
280
+ "restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
281
+ "list_machine_sessions", "attach_machine_session",
282
+ ]);
283
+ /** Filter tools based on agent config. */
284
+ export function filterToolsForAgent(agent, allTools) {
285
+ if (agent.tools && agent.tools.length > 0) {
286
+ // Agent specifies an explicit allowlist — give those + wiki tools
287
+ const allowed = new Set([...agent.tools, ...WIKI_TOOL_NAMES]);
288
+ return allTools.filter((t) => allowed.has(t.name));
289
+ }
290
+ // Default: all tools except management (only @chapterhouse gets those)
291
+ if (agent.slug === "chapterhouse") {
292
+ return allTools;
293
+ }
294
+ return allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
295
+ }
296
+ /** Create an ephemeral session for an agent. Always creates a fresh session — caller is responsible for destroying it. */
297
+ export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix) {
298
+ const agent = getAgent(slug);
299
+ if (!agent)
300
+ throw new Error(`Agent '${slug}' not found in registry.`);
301
+ // Explicit override always wins. Otherwise use frontmatter model (with
302
+ // fallback to sonnet for "auto" agents that receive no override).
303
+ const model = (modelOverride && modelOverride.length > 0)
304
+ ? modelOverride
305
+ : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model);
306
+ const tools = filterToolsForAgent(agent, allTools);
307
+ const mcpServers = loadMcpConfig();
308
+ const skillDirectories = getSkillDirectories();
309
+ const baseSystemMessage = composeAgentSystemMessage(agent);
310
+ const systemMessageContent = systemMessagePrefix
311
+ ? `${systemMessagePrefix}\n\n${baseSystemMessage}`
312
+ : baseSystemMessage;
313
+ const session = await client.createSession({
314
+ model,
315
+ configDir: SESSIONS_DIR,
316
+ workingDirectory: process.cwd(),
317
+ streaming: true,
318
+ systemMessage: { content: systemMessageContent },
319
+ tools,
320
+ mcpServers,
321
+ skillDirectories,
322
+ onPermissionRequest: approveAll,
323
+ infiniteSessions: {
324
+ enabled: true,
325
+ backgroundCompactionThreshold: 0.80,
326
+ bufferExhaustionThreshold: 0.95,
327
+ },
328
+ });
329
+ console.log(`[agents] Created ephemeral session for @${agent.slug} (${model})`);
330
+ return session;
331
+ }
332
+ /** Create an ephemeral session for a squad virtual agent (not in CH registry). */
333
+ export async function createSquadAgentSession(slug, client, allTools, systemMessagePrefix, modelOverride) {
334
+ const model = (modelOverride && modelOverride.length > 0)
335
+ ? modelOverride
336
+ : "claude-sonnet-4.6";
337
+ const squadTools = allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
338
+ const mcpServers = loadMcpConfig();
339
+ const skillDirectories = getSkillDirectories();
340
+ const session = await client.createSession({
341
+ model,
342
+ configDir: SESSIONS_DIR,
343
+ workingDirectory: process.cwd(),
344
+ streaming: true,
345
+ systemMessage: { content: systemMessagePrefix },
346
+ tools: squadTools,
347
+ mcpServers,
348
+ skillDirectories,
349
+ onPermissionRequest: approveAll,
350
+ infiniteSessions: {
351
+ enabled: true,
352
+ backgroundCompactionThreshold: 0.80,
353
+ bufferExhaustionThreshold: 0.95,
354
+ },
355
+ });
356
+ console.log(`[agents] Created squad virtual agent session for @${slug} (${model})`);
357
+ return session;
358
+ }
359
+ /** Clean up active task tracking (for shutdown/restart). */
360
+ export async function clearActiveTasks() {
361
+ activeTasks.clear();
362
+ }
363
+ /** Get status info for an agent (task info only — no persistent sessions). */
364
+ export function getAgentSessionStatus(slug) {
365
+ const tasks = Array.from(activeTasks.values()).filter((t) => t.agentSlug === slug);
366
+ return {
367
+ taskCount: tasks.length,
368
+ tasks,
369
+ };
370
+ }
371
+ /** Get all active tasks. */
372
+ export function getActiveTasks() {
373
+ return Array.from(activeTasks.values());
374
+ }
375
+ /** Get a task by ID. */
376
+ export function getTask(taskId) {
377
+ return activeTasks.get(taskId);
378
+ }
379
+ /** Register a new task. */
380
+ export function registerTask(agentSlug, description, originChannel) {
381
+ const task = {
382
+ taskId: nextTaskId(),
383
+ agentSlug,
384
+ description,
385
+ status: "running",
386
+ startedAt: Date.now(),
387
+ originChannel,
388
+ };
389
+ activeTasks.set(task.taskId, task);
390
+ return task;
391
+ }
392
+ /** Mark a task as completed. */
393
+ export function completeTask(taskId, result) {
394
+ const task = activeTasks.get(taskId);
395
+ if (task) {
396
+ task.status = "completed";
397
+ task.result = result;
398
+ task.completedAt = Date.now();
399
+ }
400
+ }
401
+ /** Mark a task as failed. */
402
+ export function failTask(taskId, error) {
403
+ const task = activeTasks.get(taskId);
404
+ if (task) {
405
+ task.status = "error";
406
+ task.result = error;
407
+ task.completedAt = Date.now();
408
+ }
409
+ }
410
+ // ---------------------------------------------------------------------------
411
+ // @mention routing
412
+ // ---------------------------------------------------------------------------
413
+ /** Active agent per conversation channel (sticky routing). */
414
+ const activeAgentByChannel = new Map();
415
+ /** Get the active agent for a channel. Returns "chapterhouse" if none set. */
416
+ export function getActiveAgent(channel) {
417
+ return activeAgentByChannel.get(channel) || "chapterhouse";
418
+ }
419
+ /** Set the active agent for a channel. */
420
+ export function setActiveAgent(channel, slug) {
421
+ activeAgentByChannel.set(channel, slug);
422
+ }
423
+ /** Parse @mention from message text. Returns agent slug and remaining message, or null. */
424
+ export function parseAtMention(text, projectRoot) {
425
+ const match = text.match(/^@([a-zA-Z0-9-]+)\s*([\s\S]*)$/);
426
+ if (!match)
427
+ return null;
428
+ const mentionedName = match[1].toLowerCase();
429
+ const message = match[2].trim();
430
+ const agent = getAgent(mentionedName);
431
+ if (agent) {
432
+ return { agentSlug: agent.slug, message: message || "" };
433
+ }
434
+ if (projectRoot) {
435
+ try {
436
+ const squadAgent = findSquadAgent(projectRoot, mentionedName);
437
+ if (squadAgent) {
438
+ return { agentSlug: mentionedName, message: message || "" };
439
+ }
440
+ }
441
+ catch {
442
+ // squad lookup failed — fall through
443
+ }
444
+ }
445
+ return null;
446
+ }
447
+ //# sourceMappingURL=agents.js.map
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const fixtureRoot = join(repoRoot, "src/test/fixtures/mock-squad-repo");
7
+ const sandboxRoot = join(repoRoot, ".test-work", `agents-squad-${process.pid}`);
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ async function loadAgentsModule() {
10
+ return await import(new URL(`./agents.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
11
+ }
12
+ test.before(() => {
13
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
14
+ });
15
+ test.beforeEach(() => {
16
+ rmSync(sandboxRoot, { recursive: true, force: true });
17
+ mkdirSync(join(sandboxRoot, ".chapterhouse"), { recursive: true });
18
+ });
19
+ test.after(() => {
20
+ rmSync(sandboxRoot, { recursive: true, force: true });
21
+ });
22
+ test("buildAgentRoster includes squad agents when a project root is active", async () => {
23
+ const m = await loadAgentsModule();
24
+ m.ensureDefaultAgents();
25
+ m.loadAgents();
26
+ const roster = m.buildAgentRoster(fixtureRoot);
27
+ assert.match(roster, /@coder/);
28
+ assert.match(roster, /@ripley/);
29
+ });
30
+ test("parseAtMention resolves squad agents when project context is active", async () => {
31
+ const m = await loadAgentsModule();
32
+ m.ensureDefaultAgents();
33
+ m.loadAgents();
34
+ const result = m.parseAtMention("@ripley trace squad decisions", fixtureRoot);
35
+ assert.deepEqual(result, {
36
+ agentSlug: "ripley",
37
+ message: "trace squad decisions",
38
+ });
39
+ });
40
+ test("createEphemeralAgentSession prepends an optional system message prefix", async () => {
41
+ const m = await loadAgentsModule();
42
+ m.ensureDefaultAgents();
43
+ m.loadAgents();
44
+ let options;
45
+ const client = {
46
+ async createSession(nextOptions) {
47
+ options = nextOptions;
48
+ return { sessionId: "ephemeral-1" };
49
+ },
50
+ };
51
+ await m.createEphemeralAgentSession("coder", client, [{ name: "wiki_read" }], undefined, "PROJECT CONTEXT PREFIX");
52
+ assert.match(String(options?.systemMessage?.content || ""), /PROJECT CONTEXT PREFIX/);
53
+ });
54
+ test("createSquadAgentSession uses the provided system prefix and strips management tools", async () => {
55
+ const m = await loadAgentsModule();
56
+ assert.equal(typeof m.createSquadAgentSession, "function", "createSquadAgentSession should be exported");
57
+ let options;
58
+ const client = {
59
+ async createSession(nextOptions) {
60
+ options = nextOptions;
61
+ return { sessionId: "squad-1" };
62
+ },
63
+ };
64
+ await m.createSquadAgentSession("ripley", client, [
65
+ { name: "delegate_to_agent" },
66
+ { name: "wiki_read" },
67
+ { name: "bash" },
68
+ ], "SQUAD PREFIX");
69
+ assert.equal(options?.systemMessage?.content, "SQUAD PREFIX");
70
+ assert.deepEqual((options?.tools).map((tool) => tool.name).sort(), ["bash", "wiki_read"]);
71
+ });
72
+ //# sourceMappingURL=agents.squad.test.js.map
@@ -0,0 +1,72 @@
1
+ import { approveAll } from "@github/copilot-sdk";
2
+ // ---------------------------------------------------------------------------
3
+ // Persistent GPT-4.1 classifier session
4
+ // ---------------------------------------------------------------------------
5
+ const CLASSIFIER_MODEL = "gpt-4.1";
6
+ const CLASSIFY_TIMEOUT_MS = 8_000;
7
+ const SYSTEM_PROMPT = `You are a message complexity classifier for an AI assistant called Chapterhouse. Your ONLY job is to classify incoming user messages into one of three tiers. Respond with ONLY the tier name — nothing else.
8
+
9
+ Tiers:
10
+ - FAST: Greetings, thanks, acknowledgments, simple yes/no, trivial factual questions ("what time is it?", "hello", "thanks"), casual chat with no technical depth.
11
+ - STANDARD: Coding tasks, file operations, tool usage requests, moderate reasoning, questions about technical topics, requests to create/check/manage things, anything involving code or development workflow.
12
+ - PREMIUM: Complex architecture decisions, deep analysis, multi-step reasoning, comparing trade-offs, detailed explanations of complex topics, debugging intricate issues, designing systems, strategic planning.
13
+
14
+ Rules:
15
+ - If unsure, respond STANDARD (it's the safe default).
16
+ - Respond with exactly one word: FAST, STANDARD, or PREMIUM.`;
17
+ let classifierSession;
18
+ let sessionClient;
19
+ async function ensureSession(client) {
20
+ // Recreate if the client changed (e.g. after a reset)
21
+ if (classifierSession && sessionClient === client) {
22
+ return classifierSession;
23
+ }
24
+ // Destroy stale session
25
+ if (classifierSession) {
26
+ classifierSession.destroy().catch(() => { });
27
+ classifierSession = undefined;
28
+ }
29
+ classifierSession = await client.createSession({
30
+ model: CLASSIFIER_MODEL,
31
+ streaming: false,
32
+ systemMessage: { content: SYSTEM_PROMPT },
33
+ onPermissionRequest: approveAll,
34
+ });
35
+ sessionClient = client;
36
+ return classifierSession;
37
+ }
38
+ const TIER_MAP = {
39
+ FAST: "fast",
40
+ STANDARD: "standard",
41
+ PREMIUM: "premium",
42
+ };
43
+ /**
44
+ * Classify a message using GPT-4.1.
45
+ * Returns the tier, or null if the classifier is unavailable / times out.
46
+ */
47
+ export async function classifyWithLLM(client, message) {
48
+ try {
49
+ const session = await ensureSession(client);
50
+ const result = await session.sendAndWait({ prompt: message }, CLASSIFY_TIMEOUT_MS);
51
+ const raw = (result?.data?.content || "").trim().toUpperCase();
52
+ return TIER_MAP[raw] ?? "standard";
53
+ }
54
+ catch (err) {
55
+ console.log(`[chapterhouse] Classifier error (falling back to heuristics): ${err instanceof Error ? err.message : err}`);
56
+ // Destroy broken session so it's recreated next time
57
+ if (classifierSession) {
58
+ classifierSession.destroy().catch(() => { });
59
+ classifierSession = undefined;
60
+ }
61
+ return null;
62
+ }
63
+ }
64
+ /** Tear down the classifier session (e.g. on shutdown). */
65
+ export function stopClassifier() {
66
+ if (classifierSession) {
67
+ classifierSession.destroy().catch(() => { });
68
+ classifierSession = undefined;
69
+ sessionClient = undefined;
70
+ }
71
+ }
72
+ //# sourceMappingURL=classifier.js.map
@@ -0,0 +1,32 @@
1
+ import { CopilotClient } from "@github/copilot-sdk";
2
+ import { config } from "../config.js";
3
+ let client;
4
+ export async function getClient() {
5
+ if (!client) {
6
+ client = new CopilotClient({
7
+ autoStart: true,
8
+ autoRestart: true,
9
+ gitHubToken: config.copilotAuthToken || undefined,
10
+ });
11
+ await client.start();
12
+ }
13
+ return client;
14
+ }
15
+ /** Tear down the existing client and create a fresh one. */
16
+ export async function resetClient() {
17
+ if (client) {
18
+ try {
19
+ await client.stop();
20
+ }
21
+ catch { /* best-effort */ }
22
+ client = undefined;
23
+ }
24
+ return getClient();
25
+ }
26
+ export async function stopClient() {
27
+ if (client) {
28
+ await client.stop();
29
+ client = undefined;
30
+ }
31
+ }
32
+ //# sourceMappingURL=client.js.map