chapterhouse 0.6.0 → 0.8.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 (80) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/agent-edit-access.js +11 -0
  3. package/dist/api/agents.api.test.js +48 -0
  4. package/dist/api/korg.js +34 -0
  5. package/dist/api/korg.test.js +42 -0
  6. package/dist/api/server.js +420 -13
  7. package/dist/api/server.test.js +533 -3
  8. package/dist/config.js +28 -0
  9. package/dist/config.test.js +20 -0
  10. package/dist/copilot/agent-event-bus.js +1 -0
  11. package/dist/copilot/agents.js +117 -50
  12. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  13. package/dist/copilot/agents.parse.test.js +69 -0
  14. package/dist/copilot/agents.test.js +137 -2
  15. package/dist/copilot/orchestrator.js +62 -13
  16. package/dist/copilot/orchestrator.test.js +130 -8
  17. package/dist/copilot/session-manager.js +34 -0
  18. package/dist/copilot/system-message.js +11 -10
  19. package/dist/copilot/system-message.test.js +6 -1
  20. package/dist/copilot/tools.js +184 -376
  21. package/dist/copilot/tools.memory.test.js +32 -0
  22. package/dist/copilot/tools.wiki.test.js +53 -59
  23. package/dist/daemon.js +9 -0
  24. package/dist/memory/decisions.js +6 -5
  25. package/dist/memory/entities.js +20 -9
  26. package/dist/memory/hooks.js +151 -0
  27. package/dist/memory/hooks.test.js +325 -0
  28. package/dist/memory/hot-tier.js +37 -0
  29. package/dist/memory/hot-tier.test.js +30 -0
  30. package/dist/memory/housekeeping-scheduler.js +35 -0
  31. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  32. package/dist/memory/inbox.js +10 -0
  33. package/dist/memory/index.js +3 -1
  34. package/dist/memory/migration.js +244 -0
  35. package/dist/memory/migration.test.js +100 -0
  36. package/dist/memory/reflect.js +273 -0
  37. package/dist/memory/reflect.test.js +254 -0
  38. package/dist/store/db.js +119 -4
  39. package/dist/store/db.test.js +19 -1
  40. package/dist/test/setup-env.js +3 -1
  41. package/dist/test/setup-env.test.js +8 -1
  42. package/dist/wiki/consolidation.js +641 -0
  43. package/dist/wiki/consolidation.test.js +140 -0
  44. package/dist/wiki/frontmatter.js +48 -0
  45. package/dist/wiki/frontmatter.test.js +42 -0
  46. package/dist/wiki/index-manager.js +246 -330
  47. package/dist/wiki/index-manager.test.js +138 -145
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/migrate-topics.test.js +16 -6
  53. package/dist/wiki/scheduler.js +118 -0
  54. package/dist/wiki/scheduler.test.js +64 -0
  55. package/dist/wiki/timeline.js +51 -0
  56. package/dist/wiki/timeline.test.js +65 -0
  57. package/dist/wiki/topic-structure.js +1 -1
  58. package/package.json +3 -1
  59. package/skills/pkb-ideas/SKILL.md +78 -0
  60. package/skills/pkb-ideas/_meta.json +4 -0
  61. package/skills/pkb-org/SKILL.md +82 -0
  62. package/skills/pkb-org/_meta.json +4 -0
  63. package/skills/pkb-people/SKILL.md +74 -0
  64. package/skills/pkb-people/_meta.json +4 -0
  65. package/skills/pkb-research/SKILL.md +83 -0
  66. package/skills/pkb-research/_meta.json +4 -0
  67. package/skills/pkb-source/SKILL.md +38 -0
  68. package/skills/pkb-source/_meta.json +4 -0
  69. package/skills/wiki-conventions/SKILL.md +5 -5
  70. package/web/dist/assets/index-5kz9aRU9.css +10 -0
  71. package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
  72. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  73. package/web/dist/index.html +2 -2
  74. package/dist/wiki/context.js +0 -138
  75. package/dist/wiki/fix.js +0 -335
  76. package/dist/wiki/fix.test.js +0 -350
  77. package/dist/wiki/lint.js +0 -451
  78. package/dist/wiki/lint.test.js +0 -329
  79. package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
  80. package/web/dist/assets/index-DknKAtDS.css +0 -10
@@ -126,6 +126,26 @@ test("defaults memory checkpoint turns to 5 and parses integer overrides", async
126
126
  assert.equal(parsedThree.memoryCheckpointTurns, 3);
127
127
  assert.equal(parsedTen.memoryCheckpointTurns, 10);
128
128
  });
129
+ test("parses PKB consolidation defaults and explicit hour overrides", async () => {
130
+ const configModule = await import("./config.js");
131
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
132
+ const parsedDefault = configModule.parseRuntimeConfig({});
133
+ const parsedExplicit = configModule.parseRuntimeConfig({
134
+ CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED: "false",
135
+ CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR: "5",
136
+ });
137
+ assert.equal(parsedDefault.pkbConsolidationEnabled, true);
138
+ assert.equal(parsedDefault.pkbConsolidationHour, 3);
139
+ assert.equal(parsedExplicit.pkbConsolidationEnabled, false);
140
+ assert.equal(parsedExplicit.pkbConsolidationHour, 5);
141
+ });
142
+ test("rejects invalid PKB consolidation hours outside 0-23", async () => {
143
+ const configModule = await import("./config.js");
144
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
145
+ assert.throws(() => configModule.parseRuntimeConfig({
146
+ CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR: "24",
147
+ }), /CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR must be an integer between 0 and 23/);
148
+ });
129
149
  test("defaults memory injection on and still honors explicit CHAPTERHOUSE_MEMORY_INJECT overrides", async () => {
130
150
  const configModule = await import("./config.js");
131
151
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
@@ -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`);
@@ -240,7 +299,7 @@ Chapterhouse agent memory follows a three-tier memory model: **read** with \`mem
240
299
  ### Shared Wiki
241
300
  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.
242
301
 
243
- Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wiki_update\`, \`remember\`, \`forget\`, \`wiki_ingest\`, \`wiki_lint\`, and \`wiki_rebuild_index\` as write-sensitive workflows. Before using them, read \`pages/index.md\`, scan the last 20-30 entries of \`pages/_meta/log.md\`, and run \`wiki_search\` for the topic when the wiki is large or the topic is ambiguous.
302
+ Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wiki_update\` and \`wiki_ingest_source\` as write-sensitive workflows, and use \`memory_propose\` for agent-memory handoff. Before using wiki write tools, read \`pages/index.md\`, scan the last 20-30 entries of \`pages/_meta/log.md\`, and run \`wiki_search\` for the topic when the wiki is large or the topic is ambiguous.
244
303
 
245
304
  ### Communication
246
305
  - You receive tasks from @chapterhouse (the orchestrator) or directly from the user
@@ -281,8 +340,7 @@ export function buildAgentRoster() {
281
340
  }
282
341
  // The wiki tools that every agent gets regardless of tool config
283
342
  const WIKI_TOOL_NAMES = new Set([
284
- "wiki_search", "wiki_read", "wiki_update", "remember", "recall", "forget",
285
- "wiki_ingest", "wiki_lint", "wiki_rebuild_index",
343
+ "wiki_search", "wiki_read", "wiki_update", "wiki_append_timeline", "wiki_ingest_source",
286
344
  "memory_recall", "memory_propose", "memory_list_action_items",
287
345
  ]);
288
346
  // Management tools that only @chapterhouse should have
@@ -292,7 +350,7 @@ const MANAGEMENT_TOOL_NAMES = new Set([
292
350
  "switch_model", "toggle_auto", "list_models",
293
351
  "restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
294
352
  "list_machine_sessions", "attach_machine_session",
295
- "memory_remember", "memory_set_scope", "memory_housekeep", "memory_promote", "memory_demote",
353
+ "memory_remember", "memory_set_scope", "memory_housekeep", "memory_reflect", "memory_promote", "memory_demote",
296
354
  "memory_add_action_item", "memory_complete_action_item", "memory_drop_action_item", "memory_snooze_action_item",
297
355
  ]);
298
356
  export function getCurrentToolAgentSlug() {
@@ -323,6 +381,14 @@ export function filterToolsForAgent(agent, allTools) {
323
381
  }
324
382
  return allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
325
383
  }
384
+ /** Filter MCP servers based on agent config. */
385
+ export function filterMcpServersForAgent(agent, allMcpServers) {
386
+ if (agent.mcpServers && agent.mcpServers.length > 0) {
387
+ const allowed = new Set(agent.mcpServers);
388
+ return Object.fromEntries(Object.entries(allMcpServers).filter(([name]) => allowed.has(name)));
389
+ }
390
+ return allMcpServers;
391
+ }
326
392
  /** Create an ephemeral session for an agent. Always creates a fresh session — caller is responsible for destroying it. */
327
393
  export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix, taskId) {
328
394
  const agent = getAgent(slug);
@@ -334,7 +400,8 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
334
400
  ? modelOverride
335
401
  : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model);
336
402
  const tools = bindToolsToAgent(agent.slug, filterToolsForAgent(agent, allTools), taskId);
337
- const mcpServers = loadMcpConfig();
403
+ const allMcpServers = loadMcpConfig();
404
+ const mcpServers = filterMcpServersForAgent(agent, allMcpServers);
338
405
  const skillDirectories = getSkillDirectories();
339
406
  const baseSystemMessage = composeAgentSystemMessage(agent);
340
407
  const systemMessageContent = systemMessagePrefix
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ const MCP_SERVERS = {
7
+ filesystem: { command: "filesystem" },
8
+ truenas: { command: "truenas" },
9
+ };
10
+ async function loadIsolatedAgentsModule(t, agentFrontmatter) {
11
+ const root = mkdtempSync(join(tmpdir(), "chapterhouse-agents-mcp-"));
12
+ const agentsDir = join(root, "agents");
13
+ const sessionsDir = join(root, "sessions");
14
+ mkdirSync(agentsDir, { recursive: true });
15
+ mkdirSync(sessionsDir, { recursive: true });
16
+ writeFileSync(join(agentsDir, "mcp-test-agent.agent.md"), [
17
+ "---",
18
+ "name: MCP Test Agent",
19
+ "description: Agent used to verify MCP session config",
20
+ "model: claude-sonnet-4.6",
21
+ agentFrontmatter,
22
+ "---",
23
+ "",
24
+ "You are an MCP test agent.",
25
+ ].filter(Boolean).join("\n"));
26
+ t.after(() => rmSync(root, { recursive: true, force: true }));
27
+ t.mock.module("../paths.js", {
28
+ namedExports: {
29
+ AGENTS_DIR: agentsDir,
30
+ SESSIONS_DIR: sessionsDir,
31
+ },
32
+ });
33
+ t.mock.module("./mcp-config.js", {
34
+ namedExports: {
35
+ loadMcpConfig: () => MCP_SERVERS,
36
+ },
37
+ });
38
+ t.mock.module("./skills.js", {
39
+ namedExports: {
40
+ getSkillDirectories: () => [],
41
+ },
42
+ });
43
+ t.mock.module("../store/db.js", {
44
+ namedExports: {
45
+ getState: () => undefined,
46
+ setState: () => undefined,
47
+ },
48
+ });
49
+ let createSessionOptions;
50
+ const agentsModule = await import(new URL(`./agents.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
51
+ agentsModule.loadAgents();
52
+ return {
53
+ agentsModule,
54
+ get createSessionOptions() {
55
+ return createSessionOptions;
56
+ },
57
+ set createSessionOptions(value) {
58
+ createSessionOptions = value;
59
+ },
60
+ };
61
+ }
62
+ async function createAgentSession(agentsModule, setCreateSessionOptions) {
63
+ const client = {
64
+ createSession: async (options) => {
65
+ setCreateSessionOptions(options);
66
+ return {};
67
+ },
68
+ };
69
+ await agentsModule.createEphemeralAgentSession("mcp-test-agent", client, []);
70
+ }
71
+ test("createEphemeralAgentSession only passes MCP servers listed by the agent allowlist", async (t) => {
72
+ const context = await loadIsolatedAgentsModule(t, "mcpServers: [truenas]");
73
+ await createAgentSession(context.agentsModule, (options) => {
74
+ context.createSessionOptions = options;
75
+ });
76
+ assert.deepEqual(context.createSessionOptions?.mcpServers, {
77
+ truenas: { command: "truenas" },
78
+ });
79
+ });
80
+ test("createEphemeralAgentSession passes all MCP servers when the agent has no allowlist", async (t) => {
81
+ const context = await loadIsolatedAgentsModule(t, "");
82
+ await createAgentSession(context.agentsModule, (options) => {
83
+ context.createSessionOptions = options;
84
+ });
85
+ assert.deepEqual(context.createSessionOptions?.mcpServers, MCP_SERVERS);
86
+ });
87
+ //# sourceMappingURL=agents.mcp-servers.test.js.map
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { parseAgentMd } from "./agents.js";
4
+ test("parseAgentMd parses block-style YAML arrays", () => {
5
+ const agent = parseAgentMd([
6
+ "---",
7
+ "name: Designer",
8
+ "description: Handles UI flows",
9
+ "model: claude-sonnet-4.6",
10
+ "skills:",
11
+ " - frontend-design",
12
+ " - ux-copy",
13
+ "tools:",
14
+ " - read",
15
+ " - write",
16
+ "---",
17
+ "",
18
+ "You are Designer.",
19
+ ].join("\n"), "designer");
20
+ assert.ok(agent, "agent charter should still parse");
21
+ assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
22
+ assert.deepEqual(agent.tools, ["read", "write"]);
23
+ });
24
+ test("parseAgentMd still parses inline YAML arrays", () => {
25
+ const agent = parseAgentMd([
26
+ "---",
27
+ "name: Designer",
28
+ "description: Handles UI flows",
29
+ "model: claude-sonnet-4.6",
30
+ "skills: [frontend-design, ux-copy]",
31
+ "tools: [read, write]",
32
+ "---",
33
+ "",
34
+ "You are Designer.",
35
+ ].join("\n"), "designer");
36
+ assert.ok(agent, "agent charter should parse");
37
+ assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
38
+ assert.deepEqual(agent.tools, ["read", "write"]);
39
+ });
40
+ test("parseAgentMd tolerates missing optional fields", () => {
41
+ const agent = parseAgentMd([
42
+ "---",
43
+ "name: Designer",
44
+ "description: Handles UI flows",
45
+ "model: claude-sonnet-4.6",
46
+ "---",
47
+ "",
48
+ "You are Designer.",
49
+ ].join("\n"), "designer");
50
+ assert.ok(agent, "agent charter should parse");
51
+ assert.equal(agent.skills, undefined);
52
+ assert.equal(agent.tools, undefined);
53
+ assert.equal(agent.mcpServers, undefined);
54
+ assert.equal(agent.allowedPaths, undefined);
55
+ });
56
+ test("parseAgentMd returns null for invalid YAML frontmatter", () => {
57
+ const agent = parseAgentMd([
58
+ "---",
59
+ "name: Designer",
60
+ "description: Handles UI flows",
61
+ "model: claude-sonnet-4.6",
62
+ "skills: [frontend-design",
63
+ "---",
64
+ "",
65
+ "You are Designer.",
66
+ ].join("\n"), "designer");
67
+ assert.equal(agent, null);
68
+ });
69
+ //# sourceMappingURL=agents.parse.test.js.map
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import test from "node:test";
3
- import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, parseAgentMd, withToolTaskContext, } from "./agents.js";
5
+ import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, getAgentRegistry, parseAgentMd, withToolTaskContext, } from "./agents.js";
4
6
  function makeAgent(slug) {
5
7
  return {
6
8
  slug,
@@ -48,7 +50,8 @@ test("composeAgentSystemMessage steers wiki-capable agents to wiki-conventions",
48
50
  for (const slug of ["coder", "general-purpose"]) {
49
51
  const message = composeAgentSystemMessage(makeAgent(slug));
50
52
  assert.match(message, /invoke `wiki-conventions` before wiki writes/i);
51
- assert.match(message, /wiki_update[\s\S]{0,80}remember[\s\S]{0,80}forget[\s\S]{0,80}wiki_ingest[\s\S]{0,80}wiki_lint[\s\S]{0,80}wiki_rebuild_index/i);
53
+ assert.match(message, /wiki_update[\s\S]{0,120}wiki_ingest_source[\s\S]{0,120}memory_propose/i);
54
+ assert.doesNotMatch(message, /`remember`|`forget`|`wiki_ingest`(?!_source)|`wiki_lint`|`wiki_rebuild_index`/);
52
55
  assert.match(message, /read `pages\/index\.md`/i);
53
56
  assert.match(message, /scan the last 20-30 entries of `pages\/_meta\/log\.md`/i);
54
57
  }
@@ -76,6 +79,30 @@ test("parseAgentMd detects persistent agent scope from charter frontmatter", ()
76
79
  assert.equal(agent.persistent, true);
77
80
  assert.equal(agent.scope, "infra");
78
81
  });
82
+ test("parseAgentMd preserves block-style skills arrays from charter frontmatter", () => {
83
+ const agent = parseAgentMd([
84
+ "---",
85
+ "name: Designer",
86
+ "description: UI/UX design specialist",
87
+ "model: claude-opus-4.6",
88
+ "skills:",
89
+ " - frontend-design",
90
+ " - accessibility-review",
91
+ "---",
92
+ "",
93
+ "You are the Designer.",
94
+ ].join("\n"), "designer");
95
+ assert.ok(agent, "agent charter should parse");
96
+ assert.deepEqual(agent.skills, ["frontend-design", "accessibility-review"]);
97
+ });
98
+ test("korg charter is persistent and standardizes on the Summary heading", () => {
99
+ const charter = readFileSync(join(process.cwd(), "agents", "korg.agent.md"), "utf8");
100
+ const agent = parseAgentMd(charter, "korg");
101
+ assert.ok(agent, "korg charter should parse");
102
+ assert.equal(agent.persistent, true);
103
+ assert.match(charter, /## Summary/);
104
+ assert.doesNotMatch(charter, /## Compiled Truth/);
105
+ });
79
106
  test("persistent agents cannot receive scope-changing management tools", () => {
80
107
  const agent = {
81
108
  ...makeAgent("bellonda"),
@@ -102,4 +129,112 @@ test("bindToolsToAgent uses the per-turn task context when no fixed task id is p
102
129
  const taskId = await withToolTaskContext("delegated-persistent-001", () => tools[0].handler({}, {}));
103
130
  assert.equal(taskId, "delegated-persistent-001");
104
131
  });
132
+ test("notifyAgentSaved uses the provided charter instead of rereading from CHAPTERHOUSE_HOME", async () => {
133
+ const slug = "notify-agent-saved-inline-charter";
134
+ const agents = await import("./agents.js");
135
+ const config = {
136
+ ...makeAgent(slug),
137
+ persistent: true,
138
+ scope: "infra",
139
+ };
140
+ agents.setAgentSaveRuntimeHooks({
141
+ reloadPersistentSession: async () => "none",
142
+ });
143
+ try {
144
+ await agents.notifyAgentSaved(slug, config);
145
+ }
146
+ finally {
147
+ agents.resetAgentSaveRuntimeHooks();
148
+ }
149
+ const saved = getAgentRegistry().find((entry) => entry.slug === slug);
150
+ assert.deepEqual(saved, config);
151
+ });
152
+ test("notifyAgentSaved reloads idle persistent sessions and emits a restart event", async () => {
153
+ const events = [];
154
+ const reloads = [];
155
+ const agents = await import("./agents.js");
156
+ const config = {
157
+ ...makeAgent("bellonda"),
158
+ persistent: true,
159
+ scope: "infra",
160
+ };
161
+ agents.setAgentSaveRuntimeHooks({
162
+ reloadPersistentSession: async (slug) => {
163
+ reloads.push(slug);
164
+ return "reloaded";
165
+ },
166
+ emitAgentReloadEvent: (event) => {
167
+ events.push(event);
168
+ },
169
+ });
170
+ try {
171
+ await agents.notifyAgentSaved("bellonda", config);
172
+ }
173
+ finally {
174
+ agents.resetAgentSaveRuntimeHooks();
175
+ }
176
+ assert.deepEqual(reloads, ["bellonda"]);
177
+ assert.deepEqual(events, [{ type: "agent_reloaded", slug: "bellonda", reason: "session_restart" }]);
178
+ });
179
+ test("notifyAgentSaved marks in-flight persistent sessions as pending and emits restart after the deferred reload", async () => {
180
+ const events = [];
181
+ const reloads = [];
182
+ const agents = await import("./agents.js");
183
+ const config = {
184
+ ...makeAgent("bellonda"),
185
+ persistent: true,
186
+ scope: "infra",
187
+ };
188
+ let finishReload;
189
+ agents.setAgentSaveRuntimeHooks({
190
+ reloadPersistentSession: async (slug, onReloaded) => {
191
+ reloads.push(slug);
192
+ finishReload = onReloaded;
193
+ return "scheduled";
194
+ },
195
+ emitAgentReloadEvent: (event) => {
196
+ events.push(event);
197
+ },
198
+ });
199
+ try {
200
+ await agents.notifyAgentSaved("bellonda", config);
201
+ assert.deepEqual(events, [{ type: "agent_reload_pending", slug: "bellonda", reason: "in_flight" }]);
202
+ finishReload?.();
203
+ }
204
+ finally {
205
+ agents.resetAgentSaveRuntimeHooks();
206
+ }
207
+ assert.deepEqual(reloads, ["bellonda"]);
208
+ assert.deepEqual(events, [
209
+ { type: "agent_reload_pending", slug: "bellonda", reason: "in_flight" },
210
+ { type: "agent_reloaded", slug: "bellonda", reason: "session_restart" },
211
+ ]);
212
+ });
213
+ test("notifyAgentSaved leaves agents without open persistent sessions alone", async () => {
214
+ const events = [];
215
+ const reloads = [];
216
+ const agents = await import("./agents.js");
217
+ const config = {
218
+ ...makeAgent("bellonda"),
219
+ persistent: true,
220
+ scope: "infra",
221
+ };
222
+ agents.setAgentSaveRuntimeHooks({
223
+ reloadPersistentSession: async (slug) => {
224
+ reloads.push(slug);
225
+ return "none";
226
+ },
227
+ emitAgentReloadEvent: (event) => {
228
+ events.push(event);
229
+ },
230
+ });
231
+ try {
232
+ await agents.notifyAgentSaved("bellonda", config);
233
+ }
234
+ finally {
235
+ agents.resetAgentSaveRuntimeHooks();
236
+ }
237
+ assert.deepEqual(reloads, ["bellonda"]);
238
+ assert.deepEqual(events, []);
239
+ });
105
240
  //# sourceMappingURL=agents.test.js.map