chapterhouse 0.5.2 → 0.7.0

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 (40) hide show
  1. package/.pr-types.json +14 -0
  2. package/README.md +6 -0
  3. package/dist/api/agent-edit-access.js +11 -0
  4. package/dist/api/agents.api.test.js +48 -0
  5. package/dist/api/server.js +182 -11
  6. package/dist/api/server.test.js +334 -3
  7. package/dist/config.test.js +29 -0
  8. package/dist/copilot/agent-event-bus.js +1 -0
  9. package/dist/copilot/agents.js +114 -46
  10. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  11. package/dist/copilot/agents.parse.test.js +69 -0
  12. package/dist/copilot/agents.test.js +125 -1
  13. package/dist/copilot/memory-coordinator.js +234 -0
  14. package/dist/copilot/memory-coordinator.test.js +257 -0
  15. package/dist/copilot/orchestrator.js +81 -221
  16. package/dist/copilot/orchestrator.test.js +238 -1
  17. package/dist/copilot/pr-title.js +92 -0
  18. package/dist/copilot/pr-title.test.js +54 -0
  19. package/dist/copilot/router.test.js +30 -0
  20. package/dist/copilot/session-manager.js +34 -0
  21. package/dist/copilot/threat-model.js +50 -0
  22. package/dist/copilot/threat-model.test.js +129 -0
  23. package/dist/copilot/tools.js +61 -37
  24. package/dist/copilot/tools.wiki.test.js +15 -6
  25. package/dist/setup.js +15 -5
  26. package/dist/setup.test.js +20 -3
  27. package/dist/sprint-merge.js +168 -0
  28. package/dist/sprint-merge.test.js +131 -0
  29. package/dist/store/db.js +63 -0
  30. package/dist/store/db.test.js +279 -0
  31. package/dist/test/setup-env.js +2 -1
  32. package/dist/test/setup-env.test.js +8 -1
  33. package/package.json +8 -1
  34. package/web/dist/assets/index-DuKYxMIR.css +10 -0
  35. package/web/dist/assets/index-DytB69KC.js +223 -0
  36. package/web/dist/assets/index-DytB69KC.js.map +1 -0
  37. package/web/dist/index.html +2 -2
  38. package/web/dist/assets/index-CPaILy2j.js +0 -223
  39. package/web/dist/assets/index-CPaILy2j.js.map +0 -1
  40. package/web/dist/assets/index-Cs7AGeaL.css +0 -10
@@ -124,6 +124,22 @@ function readProjectRegistryRows(testRoot) {
124
124
  db.close();
125
125
  }
126
126
  }
127
+ function getAgentFilePath(testRoot, slug) {
128
+ return join(testRoot, ".chapterhouse", "agents", `${slug}.agent.md`);
129
+ }
130
+ function writeAgentFile(testRoot, slug, content) {
131
+ mkdirSync(join(testRoot, ".chapterhouse", "agents"), { recursive: true });
132
+ writeFileSync(getAgentFilePath(testRoot, slug), content, "utf-8");
133
+ }
134
+ function markBundledAgent(testRoot, slug, hash = "bundled-hash") {
135
+ const db = new Database(getProjectDbPath(testRoot));
136
+ try {
137
+ db.prepare(`INSERT OR REPLACE INTO max_state (key, value) VALUES (?, ?)`).run(`bundled_agent_hash:${slug}.agent.md`, hash);
138
+ }
139
+ finally {
140
+ db.close();
141
+ }
142
+ }
127
143
  function setMemoryActiveScope(testRoot, slug) {
128
144
  const db = new Database(getProjectDbPath(testRoot));
129
145
  try {
@@ -192,6 +208,15 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SER
192
208
  rmSync(testRoot, { recursive: true, force: true });
193
209
  }
194
210
  }
211
+ test("agent edits require a team lead when Entra auth is enabled", async () => {
212
+ const accessModule = await import("./agent-edit-access.js");
213
+ assert.equal(typeof accessModule.assertAgentEditAccess, "function", "assertAgentEditAccess should be exported");
214
+ const assertAgentEditAccess = accessModule.assertAgentEditAccess;
215
+ assert.doesNotThrow(() => assertAgentEditAccess({ entraAuthEnabled: false }, { role: "engineer" }));
216
+ assert.throws(() => assertAgentEditAccess({ entraAuthEnabled: true }, { role: "engineer" }), /Admin access required/);
217
+ assert.throws(() => assertAgentEditAccess({ entraAuthEnabled: true }), /Admin access required/);
218
+ assert.doesNotThrow(() => assertAgentEditAccess({ entraAuthEnabled: true }, { role: "team-lead" }));
219
+ });
195
220
  test("server routes expose bootstrap and public config without auth", async () => {
196
221
  await withStartedServer(async ({ baseUrl }) => {
197
222
  const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
@@ -222,6 +247,312 @@ test("server channels route returns chapterhouse plus persistent agents in chann
222
247
  ]);
223
248
  });
224
249
  });
250
+ test("server agents routes return source-aware charter summaries and details", async () => {
251
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
252
+ writeAgentFile(testRoot, "alpha-custom", [
253
+ "---",
254
+ "name: Alpha Custom",
255
+ "description: First custom charter",
256
+ "model: gpt-5.4",
257
+ "scope: frontend",
258
+ "skills:",
259
+ " - ui-copy",
260
+ " - wireframes",
261
+ "---",
262
+ "",
263
+ "You are Alpha Custom.",
264
+ ].join("\n"));
265
+ writeAgentFile(testRoot, "zulu-custom", [
266
+ "---",
267
+ "name: Zulu Custom",
268
+ "description: Last custom charter",
269
+ "model: claude-sonnet-4.6",
270
+ "persistent: true",
271
+ "---",
272
+ "",
273
+ "You are Zulu Custom.",
274
+ ].join("\n"));
275
+ markBundledAgent(testRoot, "designer");
276
+ const listResponse = await fetch(`${baseUrl}/api/agents`, {
277
+ headers: { authorization: authHeader },
278
+ });
279
+ assert.equal(listResponse.status, 200);
280
+ const agents = await listResponse.json();
281
+ const firstCustomIndex = agents.findIndex((agent) => agent.type === "custom");
282
+ assert.notEqual(firstCustomIndex, -1);
283
+ assert.ok(agents.slice(0, firstCustomIndex).every((agent) => agent.type === "builtin"));
284
+ const customAgents = agents.filter((agent) => agent.type === "custom");
285
+ assert.deepEqual(customAgents.map((agent) => agent.name), ["Alpha Custom", "Zulu Custom"]);
286
+ const designer = agents.find((agent) => agent.slug === "designer");
287
+ assert.ok(designer);
288
+ assert.equal(designer.type, "builtin");
289
+ assert.equal(typeof designer.lastEdited, "string");
290
+ const alpha = agents.find((agent) => agent.slug === "alpha-custom");
291
+ assert.deepEqual(alpha, {
292
+ name: "Alpha Custom",
293
+ slug: "alpha-custom",
294
+ description: "First custom charter",
295
+ model: "gpt-5.4",
296
+ scope: "frontend",
297
+ type: "custom",
298
+ lastEdited: alpha?.lastEdited ?? null,
299
+ });
300
+ assert.equal(typeof alpha?.lastEdited, "string");
301
+ const detailResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("alpha-custom")}`, {
302
+ headers: { authorization: authHeader },
303
+ });
304
+ assert.equal(detailResponse.status, 200);
305
+ assert.deepEqual(await detailResponse.json(), {
306
+ name: "Alpha Custom",
307
+ slug: "alpha-custom",
308
+ description: "First custom charter",
309
+ model: "gpt-5.4",
310
+ scope: "frontend",
311
+ persistent: false,
312
+ skills: ["ui-copy", "wireframes"],
313
+ type: "custom",
314
+ editable: true,
315
+ systemPrompt: "You are Alpha Custom.",
316
+ });
317
+ const builtinDetailResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("designer")}`, {
318
+ headers: { authorization: authHeader },
319
+ });
320
+ assert.equal(builtinDetailResponse.status, 200);
321
+ const builtinDetail = await builtinDetailResponse.json();
322
+ assert.equal(builtinDetail.slug, "designer");
323
+ assert.equal(builtinDetail.type, "builtin");
324
+ assert.equal(builtinDetail.editable, false);
325
+ assert.equal(typeof builtinDetail.systemPrompt, "string");
326
+ assert.ok(builtinDetail.systemPrompt.length > 0);
327
+ const missingResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("missing-agent")}`, {
328
+ headers: { authorization: authHeader },
329
+ });
330
+ assert.equal(missingResponse.status, 404);
331
+ assert.deepEqual(await missingResponse.json(), { error: "Agent 'missing-agent' not found" });
332
+ });
333
+ });
334
+ test("server rejects invalid agent slugs before reading agent files", async () => {
335
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
336
+ writeFileSync(join(testRoot, "some-path.agent.md"), [
337
+ "---",
338
+ "name: Escaped Agent",
339
+ "description: Should never be readable",
340
+ "model: gpt-5.4",
341
+ "---",
342
+ "",
343
+ "You should not see this.",
344
+ ].join("\n"), "utf-8");
345
+ const response = await fetch(`${baseUrl}/api/agents/..%2F..%2Fsome-path`, {
346
+ headers: { authorization: authHeader },
347
+ });
348
+ assert.equal(response.status, 400);
349
+ assert.deepEqual(await response.json(), { error: "Invalid slug" });
350
+ });
351
+ });
352
+ test("server patches a custom agent file and reloads the persistent agent registry", async () => {
353
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
354
+ writeAgentFile(testRoot, "scribe", [
355
+ "---",
356
+ "name: Scribe",
357
+ "description: Drafts release notes",
358
+ "model: claude-sonnet-4.6",
359
+ "persistent: true",
360
+ "scope: docs",
361
+ "skills:",
362
+ " - writing",
363
+ "tools:",
364
+ " - bash",
365
+ "---",
366
+ "",
367
+ "Original system prompt.",
368
+ ].join("\n"));
369
+ const before = await fetch(`${baseUrl}/api/channels`, {
370
+ headers: { authorization: authHeader },
371
+ });
372
+ assert.equal(before.status, 200);
373
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
374
+ method: "PATCH",
375
+ headers: {
376
+ authorization: authHeader,
377
+ "content-type": "application/json",
378
+ },
379
+ body: JSON.stringify({
380
+ name: "Scribe Prime",
381
+ description: "Publishes saved agent edits",
382
+ systemPrompt: "# Updated Prompt\n\nShip it.",
383
+ }),
384
+ });
385
+ assert.equal(response.status, 200);
386
+ assert.deepEqual(await response.json(), {
387
+ name: "Scribe Prime",
388
+ slug: "scribe",
389
+ description: "Publishes saved agent edits",
390
+ model: "claude-sonnet-4.6",
391
+ scope: "docs",
392
+ persistent: true,
393
+ skills: ["writing"],
394
+ type: "custom",
395
+ editable: true,
396
+ systemPrompt: "# Updated Prompt\n\nShip it.",
397
+ });
398
+ const saved = readFileSync(getAgentFilePath(testRoot, "scribe"), "utf-8");
399
+ assert.match(saved, /^---\n[\s\S]*\n---\n\n# Updated Prompt\n\nShip it\.\n?$/);
400
+ assert.match(saved, /name: Scribe Prime/);
401
+ assert.match(saved, /description: Publishes saved agent edits/);
402
+ assert.match(saved, /model: claude-sonnet-4.6/);
403
+ assert.match(saved, /scope: docs/);
404
+ assert.match(saved, /tools:\n - bash/);
405
+ assert.match(saved, /skills:\n - writing/);
406
+ const after = await fetch(`${baseUrl}/api/channels`, {
407
+ headers: { authorization: authHeader },
408
+ });
409
+ assert.equal(after.status, 200);
410
+ const channels = await after.json();
411
+ assert.deepEqual(channels.find((channel) => channel.slug === "scribe"), {
412
+ key: "agent:scribe",
413
+ label: "# scribe",
414
+ slug: "scribe",
415
+ name: "Scribe Prime",
416
+ description: "Publishes saved agent edits",
417
+ scope: "docs",
418
+ });
419
+ });
420
+ });
421
+ test("server rejects PATCH /api/agents/:slug when the request changes skills", async () => {
422
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
423
+ writeAgentFile(testRoot, "scribe", [
424
+ "---",
425
+ "name: Scribe",
426
+ "description: Drafts release notes",
427
+ "model: claude-sonnet-4.6",
428
+ "skills:",
429
+ " - writing",
430
+ "---",
431
+ "",
432
+ "Original system prompt.",
433
+ ].join("\n"));
434
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
435
+ method: "PATCH",
436
+ headers: {
437
+ authorization: authHeader,
438
+ "content-type": "application/json",
439
+ },
440
+ body: JSON.stringify({ skills: ["editing", "review"] }),
441
+ });
442
+ assert.equal(response.status, 400);
443
+ assert.match((await response.json()).error, /skills/i);
444
+ });
445
+ });
446
+ test("server rejects PATCH /api/agents/:slug when the request changes read-only fields", async () => {
447
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
448
+ writeAgentFile(testRoot, "scribe", [
449
+ "---",
450
+ "name: Scribe",
451
+ "description: Drafts release notes",
452
+ "model: claude-sonnet-4.6",
453
+ "---",
454
+ "",
455
+ "Original system prompt.",
456
+ ].join("\n"));
457
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
458
+ method: "PATCH",
459
+ headers: {
460
+ authorization: authHeader,
461
+ "content-type": "application/json",
462
+ },
463
+ body: JSON.stringify({ slug: "other-scribe" }),
464
+ });
465
+ assert.equal(response.status, 400);
466
+ assert.match((await response.json()).error, /slug/i);
467
+ });
468
+ });
469
+ test("server rejects PATCH /api/agents/:slug for bundled agents", async () => {
470
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
471
+ const response = await fetch(`${baseUrl}/api/agents/designer`, {
472
+ method: "PATCH",
473
+ headers: {
474
+ authorization: authHeader,
475
+ "content-type": "application/json",
476
+ },
477
+ body: JSON.stringify({ description: "Should not save" }),
478
+ });
479
+ assert.equal(response.status, 403);
480
+ assert.deepEqual(await response.json(), { error: "Built-in agents are read-only" });
481
+ });
482
+ });
483
+ test("server rejects PATCH /api/agents/:slug for non-team-leads in team mode", async () => {
484
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
485
+ writeAgentFile(testRoot, "scribe", [
486
+ "---",
487
+ "name: Scribe",
488
+ "description: Drafts release notes",
489
+ "model: claude-sonnet-4.6",
490
+ "---",
491
+ "",
492
+ "Original system prompt.",
493
+ ].join("\n"));
494
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
495
+ method: "PATCH",
496
+ headers: {
497
+ authorization: authHeader,
498
+ "content-type": "application/json",
499
+ },
500
+ body: JSON.stringify({ description: "Should not save" }),
501
+ });
502
+ assert.equal(response.status, 403);
503
+ assert.deepEqual(await response.json(), { error: "Forbidden" });
504
+ }, {
505
+ CHAPTERHOUSE_MODE: "team",
506
+ });
507
+ });
508
+ test("server returns 404 when PATCH /api/agents/:slug targets a missing file", async () => {
509
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
510
+ const response = await fetch(`${baseUrl}/api/agents/missing-agent`, {
511
+ method: "PATCH",
512
+ headers: {
513
+ authorization: authHeader,
514
+ "content-type": "application/json",
515
+ },
516
+ body: JSON.stringify({ description: "Still missing" }),
517
+ });
518
+ assert.equal(response.status, 404);
519
+ assert.deepEqual(await response.json(), { error: "Agent not found" });
520
+ });
521
+ });
522
+ test("server returns 400 when PATCH /api/agents/:slug cannot parse malformed agent content", async () => {
523
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
524
+ writeAgentFile(testRoot, "scribe", [
525
+ "---",
526
+ "name: Scribe",
527
+ "description: Drafts release notes",
528
+ "model: claude-sonnet-4.6",
529
+ "skills: [writing",
530
+ "---",
531
+ "",
532
+ "Original system prompt.",
533
+ ].join("\n"));
534
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
535
+ method: "PATCH",
536
+ headers: {
537
+ authorization: authHeader,
538
+ "content-type": "application/json",
539
+ },
540
+ body: JSON.stringify({ description: "Broken input stays broken" }),
541
+ });
542
+ assert.equal(response.status, 400);
543
+ assert.match((await response.json()).error, /^Invalid content:/);
544
+ });
545
+ });
546
+ test("server returns 404 when confirming reload for an unknown agent", async () => {
547
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
548
+ const response = await fetch(`${baseUrl}/api/agents/missing/reload-confirm`, {
549
+ method: "POST",
550
+ headers: { authorization: authHeader },
551
+ });
552
+ assert.equal(response.status, 404);
553
+ assert.match(await response.text(), /Agent not found/i);
554
+ });
555
+ });
225
556
  test("server runs in standalone mode without auth", async () => {
226
557
  await withStartedServer(async ({ baseUrl }) => {
227
558
  const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
@@ -916,17 +1247,17 @@ test("server caps concurrent SSE connections per IP", async () => {
916
1247
  let secondResponse;
917
1248
  try {
918
1249
  firstResponse = await fetch(`${baseUrl}/stream`, {
919
- headers: { authorization: authHeader },
1250
+ headers: { authorization: authHeader, connection: "close" },
920
1251
  signal: firstController.signal,
921
1252
  });
922
1253
  secondResponse = await fetch(`${baseUrl}/stream`, {
923
- headers: { authorization: authHeader },
1254
+ headers: { authorization: authHeader, connection: "close" },
924
1255
  signal: secondController.signal,
925
1256
  });
926
1257
  assert.equal(firstResponse.status, 200);
927
1258
  assert.equal(secondResponse.status, 200);
928
1259
  const rejected = await fetch(`${baseUrl}/stream`, {
929
- headers: { authorization: authHeader },
1260
+ headers: { authorization: authHeader, connection: "close" },
930
1261
  });
931
1262
  assert.equal(rejected.status, 429);
932
1263
  assert.equal(rejected.headers.get("retry-after"), "60");
@@ -271,7 +271,19 @@ test("rejects invalid SSE replay settings", async () => {
271
271
  CHAPTERHOUSE_SSE_BUFFER_CAPACITY: "0",
272
272
  }), /CHAPTERHOUSE_SSE_BUFFER_CAPACITY must be a positive integer/);
273
273
  });
274
+ test("personal mode starts cleanly without Entra env vars", async () => {
275
+ // Security: personal mode should boot in its intended unauthenticated configuration instead of requiring enterprise auth by accident.
276
+ const configModule = await import("./config.js");
277
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
278
+ const parsed = configModule.parseRuntimeConfig({
279
+ CHAPTERHOUSE_MODE: "personal",
280
+ });
281
+ assert.equal(parsed.chapterhouseMode, "personal");
282
+ assert.equal(parsed.entraAuthEnabled, false);
283
+ assert.doesNotMatch(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
284
+ });
274
285
  test("personal mode rejects explicit Entra auth enablement with a clear fix suggestion", async () => {
286
+ // Security: personal deployments must fail fast instead of starting in a broken auth state that looks protected but is not.
275
287
  const configModule = await import("./config.js");
276
288
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
277
289
  assert.throws(() => configModule.parseRuntimeConfig({
@@ -281,7 +293,23 @@ test("personal mode rejects explicit Entra auth enablement with a clear fix sugg
281
293
  ENTRA_CLIENT_ID: "client-id",
282
294
  }), /Personal mode cannot be used with ENTRA_AUTH_ENABLED=true[\s\S]*CHAPTERHOUSE_MODE=team[\s\S]*unset ENTRA_AUTH_ENABLED/);
283
295
  });
296
+ test("team mode accepts Entra auth when required settings are present", async () => {
297
+ // Security: team mode is the only safe place for Entra auth, so valid enterprise settings must remain supported there.
298
+ const configModule = await import("./config.js");
299
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
300
+ const parsed = configModule.parseRuntimeConfig({
301
+ CHAPTERHOUSE_MODE: "team",
302
+ ENTRA_AUTH_ENABLED: "true",
303
+ ENTRA_TENANT_ID: "tenant-id",
304
+ ENTRA_CLIENT_ID: "client-id",
305
+ });
306
+ assert.equal(parsed.chapterhouseMode, "team");
307
+ assert.equal(parsed.entraAuthEnabled, true);
308
+ assert.equal(parsed.entraTenantId, "tenant-id");
309
+ assert.equal(parsed.entraClientId, "client-id");
310
+ });
284
311
  test("personal mode still warns about leftover Entra settings when auth is not explicitly enabled", async () => {
312
+ // Security: leftover enterprise env vars should be called out so personal-mode operators know those settings are being ignored.
285
313
  const configModule = await import("./config.js");
286
314
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
287
315
  const parsed = configModule.parseRuntimeConfig({
@@ -293,6 +321,7 @@ test("personal mode still warns about leftover Entra settings when auth is not e
293
321
  assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
294
322
  });
295
323
  test("personal mode warns and disables incomplete Teams notification settings instead of throwing", async () => {
324
+ // Security: personal mode should ignore team-only integrations without partially enabling cross-tenant notification flows.
296
325
  const configModule = await import("./config.js");
297
326
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
298
327
  const parsed = configModule.parseRuntimeConfig({
@@ -11,6 +11,7 @@
11
11
  * session:tool_call — tool invoked payload: { toolName, toolArgs, resultType?, _kind?, _seq?, _ts?, _summary? }
12
12
  * session:destroyed — subagent finished payload: { agentName, reason: "complete" | "error" | "abort" }
13
13
  * session:error — subagent failed payload: { agentName, error }
14
+ * agent_saved — custom agent saved payload: { slug }
14
15
  *
15
16
  * @module copilot/agent-event-bus
16
17
  */
@@ -3,6 +3,7 @@ import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, rmSync
3
3
  import { createHash } from "crypto";
4
4
  import { join, dirname, sep } from "path";
5
5
  import { fileURLToPath } from "url";
6
+ import { load as yamlLoad, dump as yamlDump } from "js-yaml";
6
7
  import { z } from "zod";
7
8
  import { approveAll } from "@github/copilot-sdk";
8
9
  import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
@@ -34,48 +35,42 @@ const agentFrontmatterSchema = z.object({
34
35
  // Agent Registry
35
36
  // ---------------------------------------------------------------------------
36
37
  let agentRegistry = [];
38
+ const defaultAgentSaveRuntimeHooks = {
39
+ getPersistentSessionState: () => "none",
40
+ reloadPersistentSession: async () => "none",
41
+ emitAgentReloadEvent: () => { },
42
+ };
43
+ let agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
37
44
  /** Bundled agents shipped with the package */
38
45
  const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
39
- const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
40
- const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
46
+ export const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
47
+ export const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
48
+ const BUILTIN_AGENT_SLUGS = new Set(RESERVED_SLUGS);
49
+ if (existsSync(BUNDLED_AGENTS_DIR)) {
50
+ for (const file of readdirSync(BUNDLED_AGENTS_DIR)) {
51
+ if (file.endsWith(".agent.md")) {
52
+ BUILTIN_AGENT_SLUGS.add(file.replace(/\.agent\.md$/, ""));
53
+ }
54
+ }
55
+ }
56
+ export function isBuiltinAgent(slug) {
57
+ return BUILTIN_AGENT_SLUGS.has(slug);
58
+ }
41
59
  /** Parse YAML frontmatter and markdown body from an .agent.md file. */
42
- export function parseAgentMd(content, slug) {
60
+ export function parseAgentMdOrThrow(content, slug) {
43
61
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---\s*([\s\S]*)$/);
44
- if (!fmMatch)
45
- return null;
62
+ if (!fmMatch) {
63
+ throw new Error("Missing YAML frontmatter");
64
+ }
46
65
  const frontmatterRaw = fmMatch[1];
47
66
  const body = fmMatch[2].trim();
48
- // Simple YAML parser for flat + array values
49
- const parsed = {};
50
- for (const line of frontmatterRaw.split("\n")) {
51
- const idx = line.indexOf(": ");
52
- if (idx <= 0)
53
- continue;
54
- const key = line.slice(0, idx).trim();
55
- let value = line.slice(idx + 2).trim();
56
- // Handle YAML quoted strings
57
- if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
58
- value = value.slice(1, -1);
59
- }
60
- parsed[key] = value;
67
+ const loaded = yamlLoad(frontmatterRaw);
68
+ if (loaded !== undefined && (loaded === null || typeof loaded !== "object" || Array.isArray(loaded))) {
69
+ throw new Error("Agent frontmatter must be a YAML object");
61
70
  }
62
- // Parse arrays from YAML inline syntax: [a, b, c]
63
- for (const key of ["skills", "tools", "mcpServers", "allowed_paths"]) {
64
- const raw = parsed[key];
65
- if (typeof raw === "string") {
66
- const arrMatch = raw.match(/^\[(.*)\]$/);
67
- if (arrMatch) {
68
- parsed[key] = arrMatch[1]
69
- .split(",")
70
- .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
71
- .filter(Boolean);
72
- }
73
- }
74
- }
75
- const result = agentFrontmatterSchema.safeParse(parsed);
71
+ const result = agentFrontmatterSchema.safeParse(loaded ?? {});
76
72
  if (!result.success) {
77
- log.warn({ slug, errors: result.error.format() }, "Invalid frontmatter in agent file");
78
- return null;
73
+ throw new Error(z.prettifyError(result.error));
79
74
  }
80
75
  const fm = result.data;
81
76
  return {
@@ -92,6 +87,29 @@ export function parseAgentMd(content, slug) {
92
87
  systemMessage: body,
93
88
  };
94
89
  }
90
+ export function parseAgentMd(content, slug) {
91
+ try {
92
+ return parseAgentMdOrThrow(content, slug);
93
+ }
94
+ catch (err) {
95
+ log.warn({ slug, err: err instanceof Error ? err.message : err }, "Invalid agent file");
96
+ return null;
97
+ }
98
+ }
99
+ export function serializeAgentMd(agent) {
100
+ const frontmatter = {
101
+ name: agent.name,
102
+ description: agent.description,
103
+ model: agent.model,
104
+ ...(agent.persistent !== undefined ? { persistent: agent.persistent } : {}),
105
+ ...(agent.scope !== undefined ? { scope: agent.scope } : {}),
106
+ ...(agent.skills !== undefined ? { skills: agent.skills } : {}),
107
+ ...(agent.tools !== undefined ? { tools: agent.tools } : {}),
108
+ ...(agent.mcpServers !== undefined ? { mcpServers: agent.mcpServers } : {}),
109
+ ...(agent.allowedPaths !== undefined ? { allowed_paths: agent.allowedPaths } : {}),
110
+ };
111
+ return `---\n${yamlDump(frontmatter, { lineWidth: -1 })}---\n\n${agent.systemMessage}`;
112
+ }
95
113
  /** Scan ~/.chapterhouse/agents/ for .agent.md files and load configs. */
96
114
  export function loadAgents() {
97
115
  if (!existsSync(AGENTS_DIR)) {
@@ -132,6 +150,49 @@ export function getAgent(nameOrSlug) {
132
150
  export function getAgentRegistry() {
133
151
  return [...agentRegistry];
134
152
  }
153
+ export function setAgentSaveRuntimeHooks(hooks) {
154
+ agentSaveRuntimeHooks = {
155
+ ...agentSaveRuntimeHooks,
156
+ ...hooks,
157
+ };
158
+ }
159
+ export function resetAgentSaveRuntimeHooks() {
160
+ agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
161
+ }
162
+ export async function notifyAgentSaved(slug, savedConfig) {
163
+ const config = savedConfig ?? parseAgentMdOrThrow(readFileSync(join(AGENTS_DIR, `${slug}.agent.md`), "utf-8"), slug);
164
+ const existingIndex = agentRegistry.findIndex((entry) => entry.slug === slug);
165
+ if (existingIndex >= 0) {
166
+ agentRegistry[existingIndex] = config;
167
+ }
168
+ else {
169
+ agentRegistry.push(config);
170
+ }
171
+ void import("./orchestrator.js").then(({ enqueueForSse }) => {
172
+ enqueueForSse({ type: "agent_saved", slug });
173
+ }).catch((err) => {
174
+ log.warn({ slug, err: err instanceof Error ? err.message : err }, "Failed to emit agent_saved SSE event");
175
+ });
176
+ const emitReloaded = () => {
177
+ agentSaveRuntimeHooks.emitAgentReloadEvent({
178
+ type: "agent_reloaded",
179
+ slug,
180
+ reason: "session_restart",
181
+ });
182
+ };
183
+ const result = await agentSaveRuntimeHooks.reloadPersistentSession(slug, emitReloaded);
184
+ if (result === "scheduled") {
185
+ agentSaveRuntimeHooks.emitAgentReloadEvent({
186
+ type: "agent_reload_pending",
187
+ slug,
188
+ reason: "in_flight",
189
+ });
190
+ return;
191
+ }
192
+ if (result === "reloaded") {
193
+ emitReloaded();
194
+ }
195
+ }
135
196
  /** Copy bundled agents to ~/.chapterhouse/agents/, updating stale copies when the bundled version changes.
136
197
  * Respects user customizations: if the user edited the deployed file after our last sync, we skip it. */
137
198
  export function ensureDefaultAgents() {
@@ -186,16 +247,14 @@ export function createAgentFile(slug, name, description, model, systemPrompt, sk
186
247
  if (existsSync(filePath)) {
187
248
  return `Agent '${slug}' already exists. Edit it directly or remove it first.`;
188
249
  }
189
- // YAML value escaping for safe frontmatter
190
- const escapedName = name.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
191
- const escapedDesc = description.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
192
- let frontmatter = `---\nname: "${escapedName}"\ndescription: "${escapedDesc}"\nmodel: ${model}`;
193
- if (skills?.length)
194
- frontmatter += `\nskills:\n${skills.map((s) => ` - ${s}`).join("\n")}`;
195
- if (tools?.length)
196
- frontmatter += `\ntools:\n${tools.map((t) => ` - ${t}`).join("\n")}`;
197
- frontmatter += "\n---\n\n";
198
- writeFileSync(filePath, frontmatter + systemPrompt + "\n");
250
+ writeFileSync(filePath, serializeAgentMd({
251
+ name,
252
+ description,
253
+ model,
254
+ skills,
255
+ tools,
256
+ systemMessage: `${systemPrompt}\n`,
257
+ }));
199
258
  return null;
200
259
  }
201
260
  /** Remove an agent .md file. Returns error string or null on success. */
@@ -203,7 +262,7 @@ export function removeAgentFile(slug) {
203
262
  if (!SLUG_REGEX.test(slug)) {
204
263
  return `Invalid slug '${slug}'.`;
205
264
  }
206
- if (RESERVED_SLUGS.has(slug)) {
265
+ if (isBuiltinAgent(slug)) {
207
266
  return `Cannot remove built-in agent '${slug}'. You can edit its file instead.`;
208
267
  }
209
268
  const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
@@ -323,6 +382,14 @@ export function filterToolsForAgent(agent, allTools) {
323
382
  }
324
383
  return allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
325
384
  }
385
+ /** Filter MCP servers based on agent config. */
386
+ export function filterMcpServersForAgent(agent, allMcpServers) {
387
+ if (agent.mcpServers && agent.mcpServers.length > 0) {
388
+ const allowed = new Set(agent.mcpServers);
389
+ return Object.fromEntries(Object.entries(allMcpServers).filter(([name]) => allowed.has(name)));
390
+ }
391
+ return allMcpServers;
392
+ }
326
393
  /** Create an ephemeral session for an agent. Always creates a fresh session — caller is responsible for destroying it. */
327
394
  export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix, taskId) {
328
395
  const agent = getAgent(slug);
@@ -334,7 +401,8 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
334
401
  ? modelOverride
335
402
  : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model);
336
403
  const tools = bindToolsToAgent(agent.slug, filterToolsForAgent(agent, allTools), taskId);
337
- const mcpServers = loadMcpConfig();
404
+ const allMcpServers = loadMcpConfig();
405
+ const mcpServers = filterMcpServersForAgent(agent, allMcpServers);
338
406
  const skillDirectories = getSkillDirectories();
339
407
  const baseSystemMessage = composeAgentSystemMessage(agent);
340
408
  const systemMessageContent = systemMessagePrefix