chapterhouse 0.5.0 → 0.5.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.
@@ -32,6 +32,7 @@ import { assertAuthenticationConfigured, createHealthPayload, createPublicConfig
32
32
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
33
33
  import { childLogger } from "../util/logger.js";
34
34
  import { getActiveScope } from "../memory/active-scope.js";
35
+ import { createScope, getScope } from "../memory/scopes.js";
35
36
  const log = childLogger("server");
36
37
  void searchIndex; // re-exported by index-manager; reference here documents the dep
37
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -81,6 +82,12 @@ const projectCreateSchema = z.object({
81
82
  cwd: requiredString("Missing 'cwd' in request body")
82
83
  .refine((value) => value.startsWith("/"), "Project cwd must be an absolute path"),
83
84
  }).strict();
85
+ const scopeCreateSchema = z.object({
86
+ slug: requiredString("Missing 'slug' in request body")
87
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Scope slug must be unique kebab-case"),
88
+ title: requiredString("Missing 'title' in request body"),
89
+ description: z.string().optional(),
90
+ }).strict();
84
91
  const projectHardRulesSchema = z.object({
85
92
  hardRules: z.object({
86
93
  auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
@@ -892,6 +899,25 @@ app.get("/api/memory/active-scope", (_req, res) => {
892
899
  title: activeScope.title,
893
900
  });
894
901
  });
902
+ app.post("/api/scopes", (req, res) => {
903
+ const body = parseRequest(scopeCreateSchema, req.body ?? {});
904
+ if (getScope(body.slug)) {
905
+ res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
906
+ return;
907
+ }
908
+ const scope = createScope({
909
+ slug: body.slug,
910
+ title: body.title,
911
+ description: body.description ?? "",
912
+ keywords: [body.slug],
913
+ });
914
+ res.status(201).json({
915
+ slug: scope.slug,
916
+ title: scope.title,
917
+ description: scope.description,
918
+ active: scope.active,
919
+ });
920
+ });
895
921
  app.post("/api/auto", (req, res) => {
896
922
  const body = parseRequest(autoRequestSchema, req.body ?? {});
897
923
  const updated = updateRouterConfig(body);
@@ -216,15 +216,10 @@ test("server channels route returns chapterhouse plus persistent agents in chann
216
216
  const channels = await response.json();
217
217
  assert.deepEqual(channels.map((channel) => channel.key), [
218
218
  "default",
219
- "agent:bellonda",
220
- "agent:hwi-noree",
221
219
  ]);
222
220
  assert.deepEqual(channels.map((channel) => channel.label), [
223
221
  "# chapterhouse",
224
- "# bellonda",
225
- "# hwi-noree",
226
222
  ]);
227
- assert.equal(channels.find((channel) => channel.key === "agent:bellonda")?.scope, "infra");
228
223
  });
229
224
  });
230
225
  test("server runs in standalone mode without auth", async () => {
@@ -267,6 +262,54 @@ test("server exposes the active memory scope API and requires auth", async () =>
267
262
  });
268
263
  });
269
264
  });
265
+ test("server creates memory scopes with duplicate and slug validation", async () => {
266
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
267
+ const unauthorized = await fetch(`${baseUrl}/api/scopes`, {
268
+ method: "POST",
269
+ headers: { "content-type": "application/json" },
270
+ body: JSON.stringify({ slug: "docs-site", title: "Docs Site" }),
271
+ });
272
+ assert.equal(unauthorized.status, 401);
273
+ const created = await fetch(`${baseUrl}/api/scopes`, {
274
+ method: "POST",
275
+ headers: {
276
+ authorization: authHeader,
277
+ "content-type": "application/json",
278
+ },
279
+ body: JSON.stringify({
280
+ slug: "docs-site",
281
+ title: "Docs Site",
282
+ description: "Documentation publishing and content workflows",
283
+ }),
284
+ });
285
+ assert.equal(created.status, 201);
286
+ assert.deepEqual(await created.json(), {
287
+ slug: "docs-site",
288
+ title: "Docs Site",
289
+ description: "Documentation publishing and content workflows",
290
+ active: true,
291
+ });
292
+ const duplicate = await fetch(`${baseUrl}/api/scopes`, {
293
+ method: "POST",
294
+ headers: {
295
+ authorization: authHeader,
296
+ "content-type": "application/json",
297
+ },
298
+ body: JSON.stringify({ slug: "docs-site", title: "Docs Site Again" }),
299
+ });
300
+ assert.equal(duplicate.status, 409);
301
+ assert.deepEqual(await duplicate.json(), { error: "Memory scope 'docs-site' already exists" });
302
+ const invalid = await fetch(`${baseUrl}/api/scopes`, {
303
+ method: "POST",
304
+ headers: {
305
+ authorization: authHeader,
306
+ "content-type": "application/json",
307
+ },
308
+ body: JSON.stringify({ slug: "Docs Site", title: "Docs Site" }),
309
+ });
310
+ assert.equal(invalid.status, 400);
311
+ });
312
+ });
270
313
  test("server bootstrap rejects non-loopback origins", async () => {
271
314
  await withStartedServer(async ({ baseUrl }) => {
272
315
  const response = await fetch(`${baseUrl}/api/bootstrap`, {
@@ -359,6 +359,17 @@ function buildHotTierContext() {
359
359
  }
360
360
  return hotTierXml.trimEnd();
361
361
  }
362
+ function buildPerTurnMemoryHooks(sessionKey) {
363
+ if (!config.memoryInjectEnabled) {
364
+ return undefined;
365
+ }
366
+ return {
367
+ onUserPromptSubmitted: () => {
368
+ const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
369
+ return hotTierXml ? { additionalContext: hotTierXml } : undefined;
370
+ },
371
+ };
372
+ }
362
373
  function getSystemMessageOptions(memorySummary) {
363
374
  return {
364
375
  selfEditEnabled: config.selfEditEnabled,
@@ -446,7 +457,7 @@ export function feedAgentResult(taskId, agentSlug, result) {
446
457
  }
447
458
  void (async () => {
448
459
  await new Promise((resolve) => setImmediate(resolve));
449
- const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. Their reply has been shown to the user. Acknowledge briefly.`;
460
+ const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.`;
450
461
  sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
451
462
  if (done && proactiveNotifyFn) {
452
463
  proactiveNotifyFn(text);
@@ -507,6 +518,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
507
518
  backgroundCompactionThreshold: 0.80,
508
519
  bufferExhaustionThreshold: 0.95,
509
520
  };
521
+ const memoryHooks = buildPerTurnMemoryHooks(sessionKey);
510
522
  let model = config.copilotModel;
511
523
  let systemMessageContent;
512
524
  let sessionMode = isProjectSession ? "project" : "default";
@@ -542,6 +554,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
542
554
  configDir: SESSIONS_DIR,
543
555
  streaming: true,
544
556
  systemMessage: { content: systemMessageContent },
557
+ hooks: memoryHooks,
545
558
  tools,
546
559
  mcpServers,
547
560
  skillDirectories,
@@ -568,6 +581,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
568
581
  configDir: SESSIONS_DIR,
569
582
  streaming: true,
570
583
  systemMessage: { content: systemMessageContent },
584
+ hooks: memoryHooks,
571
585
  tools,
572
586
  mcpServers,
573
587
  skillDirectories,
@@ -3,8 +3,12 @@ import test from "node:test";
3
3
  import { clearTurnLog, subscribeSession } from "./turn-event-log.js";
4
4
  function createFakeClient(state) {
5
5
  class FakeSession {
6
+ options;
6
7
  sessionId = "session-123";
7
8
  listeners = new Map();
9
+ constructor(options) {
10
+ this.options = options;
11
+ }
8
12
  on(eventName, handler) {
9
13
  const handlers = this.listeners.get(eventName) || [];
10
14
  handlers.push(handler);
@@ -20,6 +24,9 @@ function createFakeClient(state) {
20
24
  }
21
25
  }
22
26
  async sendAndWait(request, _timeoutMs) {
27
+ const hooks = this.options.hooks;
28
+ const hookResult = await hooks?.onUserPromptSubmitted?.({ prompt: request.prompt }, { sessionId: this.sessionId });
29
+ state.promptMemoryContexts.push(hookResult?.additionalContext);
23
30
  state.sessionPrompts.push(request);
24
31
  if (state.sendResult === "__PENDING__") {
25
32
  return await new Promise((_resolve, reject) => {
@@ -57,7 +64,7 @@ function createFakeClient(state) {
57
64
  if (state.createSessionError) {
58
65
  throw new Error(state.createSessionError);
59
66
  }
60
- const session = new FakeSession();
67
+ const session = new FakeSession(options);
61
68
  state.lastSession = {
62
69
  emit: (eventName, data) => session.emit(eventName, data),
63
70
  };
@@ -108,6 +115,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
108
115
  { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
109
116
  ],
110
117
  sendResult: "Finished successfully",
118
+ promptMemoryContexts: [],
111
119
  taskEvents: new Map(),
112
120
  projectRegistry: {},
113
121
  resolveProjectArgs: [],
@@ -155,7 +163,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
155
163
  renderHotTierForActiveScope: () => state.hotTierXml ?? "",
156
164
  getHotTierEntries: (scopeId) => ({
157
165
  scope: scopeId !== undefined
158
- ? makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
166
+ ? scopeId === state.activeScope?.id
167
+ ? state.activeScope
168
+ : makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
159
169
  : state.activeScope ?? null,
160
170
  entities: [],
161
171
  observations: [],
@@ -526,6 +536,43 @@ test("initOrchestrator passes hot-tier XML into the orchestrator system prompt w
526
536
  assert.match(hotTierXml, /<decision id="decision-1">hi<\/decision>/);
527
537
  assert.doesNotMatch(hotTierXml, /<memory_context[^>]*>[\s\S]*<memory_context\b/);
528
538
  });
539
+ test("orchestrator refreshes hot-tier memory context for each assistant turn", async (t) => {
540
+ const firstMemoryContext = [
541
+ "<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:00:00.000Z\">",
542
+ " <observation id=\"observation-1\">Initial hot memory</observation>",
543
+ "</memory_context>",
544
+ ].join("\n");
545
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
546
+ hotTierXml: firstMemoryContext,
547
+ hotTierByScope: new Map([["chapterhouse", firstMemoryContext]]),
548
+ });
549
+ await orchestrator.initOrchestrator(client);
550
+ await new Promise((resolve) => {
551
+ orchestrator.sendToOrchestrator("first turn", { type: "background" }, (text, done) => {
552
+ if (done)
553
+ resolve(text);
554
+ });
555
+ });
556
+ const secondMemoryContext = [
557
+ "<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:01:00.000Z\">",
558
+ " <observation id=\"observation-1\">Initial hot memory</observation>",
559
+ " <observation id=\"observation-2\">Checkpoint wrote this between turns</observation>",
560
+ "</memory_context>",
561
+ ].join("\n");
562
+ state.hotTierXml = secondMemoryContext;
563
+ state.hotTierByScope?.set("chapterhouse", secondMemoryContext);
564
+ await new Promise((resolve) => {
565
+ orchestrator.sendToOrchestrator("second turn", { type: "background" }, (text, done) => {
566
+ if (done)
567
+ resolve(text);
568
+ });
569
+ });
570
+ assert.equal(state.createSessionCalls.length, 1, "same SDK session should handle both turns");
571
+ assert.equal(state.promptMemoryContexts.length, 2);
572
+ assert.match(state.promptMemoryContexts[0] ?? "", /Initial hot memory/);
573
+ assert.doesNotMatch(state.promptMemoryContexts[0] ?? "", /Checkpoint wrote this between turns/);
574
+ assert.match(state.promptMemoryContexts[1] ?? "", /Checkpoint wrote this between turns/);
575
+ });
529
576
  test("initOrchestrator omits hot-tier XML when no active-scope memory is available", async (t) => {
530
577
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
531
578
  hotTierXml: "",
@@ -1194,7 +1241,7 @@ test("feedAgentResult emits an attributed short agent reply before starting the
1194
1241
  await new Promise((resolve) => setImmediate(resolve));
1195
1242
  assert.equal(await notified, "Agent complete");
1196
1243
  assert.deepEqual(state.sessionPrompts, [{
1197
- prompt: "[Agent task completed] @coder finished task task-9. Their reply has been shown to the user. Acknowledge briefly.",
1244
+ prompt: "[Agent task completed] @coder finished task task-9. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.",
1198
1245
  }]);
1199
1246
  assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
1200
1247
  const started = events.filter((event) => event.type === "turn:started");
@@ -39,7 +39,7 @@ ${hotTierBlock}
39
39
  You are a Node.js daemon process built with the Copilot SDK. Here's how you work:
40
40
 
41
41
  - **Web UI**: Your primary interface. The team talks to you in a browser tab at http://localhost:7788. Messages arrive tagged with \`[via web]\`. Markdown rendering and code blocks are fully supported, so feel free to be detailed when it helps — but stay focused.
42
- - **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated. Summarize and relay these to the team.
42
+ - **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated or system follow-ups. Only summarize background content that has not already been rendered in a separate agent bubble.
43
43
 
44
44
  When no source tag is present, assume web.
45
45
 
@@ -68,7 +68,7 @@ The \`delegate_to_agent\` tool is **non-blocking**. It dispatches the task and r
68
68
  1. When you delegate a task, acknowledge it right away. Be natural and brief: "On it — I've asked @coder to handle that." or "Sending this to @designer."
69
69
  2. You do NOT wait for the agent to finish. The tool returns immediately.
70
70
  3. After delegating, do NOT poll \`get_agent_result\` in a loop. Wait silently for the \`[Agent task completed]\` message to arrive automatically.
71
- 4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then summarize the result and relay it to the user in a clear, concise way.
71
+ 4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then follow the subagent completion rule below.
72
72
 
73
73
  You can delegate **multiple tasks simultaneously**. Different agents can work in parallel.
74
74
 
@@ -146,7 +146,7 @@ Subagent proposals from \`memory_propose\` are processed automatically at end-of
146
146
  2. **Skill-first mindset**: Search skills.sh for existing skills before building from scratch.
147
147
  3. For execution tasks, **always** delegate to a specialist agent. You cannot write code, run commands, or read files directly.
148
148
  4. **Announce your delegations**: Tell the user which agent you're sending work to and what the task is.
149
- 5. When you receive background results, summarize the key points. Don't relay the entire output verbatim.
149
+ 5. **Subagent completion rule**: Subagent replies are already shown to the user in their own bubble. When you receive \`[Agent task completed]\`, your follow-up should be a brief acknowledgment unless you have non-obvious framing or next-step decisions to add. Do NOT restate, paraphrase, or summarize the agent's content. Do NOT re-list files changed, re-state merge SHAs, re-quote the agent's bullet points, or copy the agent's table verbatim.
150
150
  6. If asked about status, check agent status and give a consolidated update.
151
151
  7. You can delegate to multiple agents simultaneously — use this for parallel work.
152
152
  8. When a task is complete, relay the results clearly.
@@ -43,6 +43,16 @@ test("orchestrator prompt tells Chapterhouse to wait for agent completion notifi
43
43
  assert.match(message, /wait silently for the `\[Agent task completed\]` message/i);
44
44
  assert.match(message, /call `get_agent_result` exactly once/i);
45
45
  });
46
+ test("orchestrator prompt tells Chapterhouse not to restate already-rendered subagent replies", () => {
47
+ const message = getOrchestratorSystemMessage();
48
+ assert.match(message, /Subagent replies are already shown to the user in their own bubble/i);
49
+ assert.match(message, /brief acknowledgment/i);
50
+ assert.match(message, /Do NOT restate, paraphrase, or summarize the agent's content/i);
51
+ assert.match(message, /re-list files changed/i);
52
+ assert.match(message, /re-state merge SHAs/i);
53
+ assert.doesNotMatch(message, /When you receive background results, summarize the key points/i);
54
+ assert.doesNotMatch(message, /summarize the result and relay it to the user/i);
55
+ });
46
56
  test("orchestrator prompt expands shorthand paths with the current home directory", () => {
47
57
  const message = getOrchestratorSystemMessage();
48
58
  assert.match(message, new RegExp(join(homedir(), "dev", "myapp").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
@@ -29,7 +29,7 @@ import { TeamsNotifier } from "../integrations/teams-notify.js";
29
29
  import { TeamPushClient } from "../integrations/team-push.js";
30
30
  import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
31
31
  import { childLogger } from "../util/logger.js";
32
- import { getActiveScope as getMemoryActiveScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
32
+ import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
33
33
  const log = childLogger("tools");
34
34
  /** Escape a string for safe inclusion as a single-line YAML scalar value. */
35
35
  function yamlEscape(value) {
@@ -66,19 +66,19 @@ function requireOrchestratorMemoryWrite() {
66
66
  return null;
67
67
  }
68
68
  function resolveProposalScopeSlug(requestedScopeSlug) {
69
- const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
70
- if (!agentSlug || agentSlug === "chapterhouse") {
69
+ if (requestedScopeSlug) {
71
70
  return requestedScopeSlug;
72
71
  }
73
- const activeScope = getMemoryActiveScope();
74
- if (activeScope) {
75
- return activeScope.slug;
72
+ const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
73
+ if (!agentSlug || agentSlug === "chapterhouse") {
74
+ return getMemoryActiveScope()?.slug;
76
75
  }
77
76
  const agent = getAgent(agentSlug);
78
- if (agent?.persistent && agent.scope) {
79
- return agent.scope;
77
+ const boundScope = agent?.scope ?? loadAgents().find((entry) => entry.slug === agentSlug)?.scope;
78
+ if (boundScope && getMemoryScope(boundScope)) {
79
+ return boundScope;
80
80
  }
81
- return requestedScopeSlug;
81
+ return getMemoryActiveScope()?.slug;
82
82
  }
83
83
  function resolveMemoryScopeForWrite(explicitScope, content) {
84
84
  const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
@@ -138,11 +138,28 @@ const entityProposalPayloadSchema = z.object({
138
138
  summary: value.summary,
139
139
  }));
140
140
  const actionItemProposalPayloadSchema = z.object({
141
- title: z.string(),
141
+ title: z.string().trim().min(1, "title is required for action_item proposals."),
142
142
  detail: z.string().optional(),
143
143
  due_at: z.string().optional(),
144
144
  source: z.string().optional(),
145
145
  entity_id: z.number().int().positive().optional(),
146
+ entity_name: z.string().trim().min(1, "entity_name must be non-empty when provided.").optional(),
147
+ entity_kind: z.string().trim().min(1, "entity_kind must be non-empty when provided.").optional(),
148
+ }).superRefine((value, context) => {
149
+ if ((value.entity_name && !value.entity_kind) || (!value.entity_name && value.entity_kind)) {
150
+ context.addIssue({
151
+ code: z.ZodIssueCode.custom,
152
+ message: "entity_name and entity_kind must be provided together for action_item proposals.",
153
+ path: ["entity_name"],
154
+ });
155
+ }
156
+ if (value.entity_id !== undefined && value.entity_name) {
157
+ context.addIssue({
158
+ code: z.ZodIssueCode.custom,
159
+ message: "Provide either entity_id or entity_name/entity_kind for action_item proposals, not both.",
160
+ path: ["entity_id"],
161
+ });
162
+ }
146
163
  });
147
164
  const memoryProposeArgsSchema = z.object({
148
165
  kind: z.enum(["observation", "decision", "entity", "action_item"]),
@@ -967,6 +984,54 @@ export function createTools(deps) {
967
984
  }
968
985
  },
969
986
  }),
987
+ defineTool("memory_create_scope", {
988
+ description: "Create a new user-defined memory scope. Slugs must be unique kebab-case; baseline installs only include global and chapterhouse.",
989
+ parameters: z.object({
990
+ slug: z.string()
991
+ .trim()
992
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be unique kebab-case"),
993
+ title: z.string().trim().min(1, "title is required"),
994
+ description: z.string().optional(),
995
+ }),
996
+ handler: async (args) => {
997
+ const denied = requireOrchestratorMemoryWrite();
998
+ if (denied)
999
+ return denied;
1000
+ const parsed = z.object({
1001
+ slug: z.string()
1002
+ .trim()
1003
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be unique kebab-case"),
1004
+ title: z.string().trim().min(1, "title is required"),
1005
+ description: z.string().optional(),
1006
+ }).safeParse(args);
1007
+ if (!parsed.success) {
1008
+ return parsed.error.issues.map((issue) => issue.message).join("; ");
1009
+ }
1010
+ try {
1011
+ if (getMemoryScope(parsed.data.slug)) {
1012
+ return `Memory scope '${parsed.data.slug}' already exists.`;
1013
+ }
1014
+ const scope = createMemoryScope({
1015
+ slug: parsed.data.slug,
1016
+ title: parsed.data.title,
1017
+ description: parsed.data.description ?? "",
1018
+ keywords: [parsed.data.slug],
1019
+ });
1020
+ return {
1021
+ ok: true,
1022
+ scope: {
1023
+ slug: scope.slug,
1024
+ title: scope.title,
1025
+ description: scope.description,
1026
+ active: scope.active,
1027
+ },
1028
+ };
1029
+ }
1030
+ catch (err) {
1031
+ return err instanceof Error ? err.message : String(err);
1032
+ }
1033
+ },
1034
+ }),
970
1035
  defineTool("memory_add_action_item", {
971
1036
  description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
972
1037
  parameters: z.object({
@@ -85,6 +85,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
85
85
  const memoryRemember = findTool(tools, "memory_remember");
86
86
  const memoryRecall = findTool(tools, "memory_recall");
87
87
  const memorySetScope = findTool(tools, "memory_set_scope");
88
+ const memoryCreateScope = findTool(tools, "memory_create_scope");
88
89
  const bindToolsToAgent = agentsModule.bindToolsToAgent;
89
90
  assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
90
91
  const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
@@ -121,6 +122,30 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
121
122
  assert.equal((recalled.hits ?? []).some((hit) => hit.id === row.id), true);
122
123
  const scopeResult = await chapterhouseSetScope.handler({ slug: "chapterhouse" }, {});
123
124
  assert.equal(scopeResult.active_scope?.slug, "chapterhouse");
125
+ const createdScope = await memoryCreateScope.handler({
126
+ slug: "docs-site",
127
+ title: "Docs Site",
128
+ description: "Documentation publishing and content workflows",
129
+ }, {});
130
+ assert.deepEqual(createdScope, {
131
+ ok: true,
132
+ scope: {
133
+ slug: "docs-site",
134
+ title: "Docs Site",
135
+ description: "Documentation publishing and content workflows",
136
+ active: true,
137
+ },
138
+ });
139
+ const duplicateScope = await memoryCreateScope.handler({
140
+ slug: "docs-site",
141
+ title: "Docs Site Again",
142
+ }, {});
143
+ assert.match(String(duplicateScope), /already exists/i);
144
+ const invalidScope = await memoryCreateScope.handler({
145
+ slug: "Docs Site",
146
+ title: "Docs Site",
147
+ }, {});
148
+ assert.match(String(invalidScope), /kebab-case/i);
124
149
  const implicitScopeRemember = await chapterhouseRemember.handler({
125
150
  content: "Implicit active-scope writes should route to chapterhouse.",
126
151
  kind: "observation",
@@ -151,7 +176,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
151
176
  }, {});
152
177
  assert.equal(typeof coderRecalled, "object");
153
178
  assert.equal((coderRecalled.hits ?? []).some((hit) => hit.id === row.id), true);
154
- assert.ok(memoryRemember && memoryRecall && memorySetScope);
179
+ assert.ok(memoryRemember && memoryRecall && memorySetScope && memoryCreateScope);
155
180
  });
156
181
  test("memory_propose queues pending proposals, defaults scope from the active scope, and captures delegated task context", async () => {
157
182
  const { toolsModule, agentsModule, dbModule } = await loadModules();
@@ -195,11 +220,10 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
195
220
  assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
196
221
  assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
197
222
  });
198
- test("memory_propose from a persistent agent is bound to that agent's scope", async () => {
223
+ test("memory_propose from a persistent agent honors an explicit scope_slug", async () => {
199
224
  const home = process.env.CHAPTERHOUSE_HOME;
200
225
  assert.ok(home, "test home should be set");
201
- const chapterhouseHome = home.endsWith(".chapterhouse") ? home : join(home, ".chapterhouse");
202
- const agentsDir = join(chapterhouseHome, "agents");
226
+ const { AGENTS_DIR: agentsDir } = await import("../paths.js");
203
227
  mkdirSync(agentsDir, { recursive: true });
204
228
  writeFileSync(join(agentsDir, "bellonda.agent.md"), [
205
229
  "---",
@@ -228,7 +252,7 @@ test("memory_propose from a persistent agent is bound to that agent's scope", as
228
252
  kind: "observation",
229
253
  scope_slug: "chapterhouse",
230
254
  payload: {
231
- content: "Persistent agents should not be able to write proposals outside their bound scope.",
255
+ content: "Persistent agents can explicitly route proposals to another scope when needed.",
232
256
  },
233
257
  }, {}));
234
258
  assert.equal(proposed.status, "queued");
@@ -241,7 +265,51 @@ test("memory_propose from a persistent agent is bound to that agent's scope", as
241
265
  assert.equal(row.source_agent, "bellonda");
242
266
  assert.equal(row.source_task_id, "task-persistent-scope-001");
243
267
  const payload = JSON.parse(row.payload);
244
- assert.equal(payload.scope_slug, "infra");
268
+ assert.equal(payload.scope_slug, "chapterhouse");
269
+ });
270
+ test("memory_propose ignores a missing subagent bound scope and falls back to the active scope", async () => {
271
+ const home = process.env.CHAPTERHOUSE_HOME;
272
+ assert.ok(home, "test home should be set");
273
+ const { AGENTS_DIR: agentsDir } = await import("../paths.js");
274
+ mkdirSync(agentsDir, { recursive: true });
275
+ writeFileSync(join(agentsDir, "hwi.agent.md"), [
276
+ "---",
277
+ "name: Hwi",
278
+ "description: Mentat of Brian's personal scope",
279
+ "model: claude-sonnet-4.6",
280
+ "persistent: true",
281
+ "scope: brian",
282
+ "---",
283
+ "",
284
+ "You are Hwi.",
285
+ ].join("\n"));
286
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
287
+ agentsModule.loadAgents();
288
+ const tools = toolsModule.createTools({
289
+ client: { async listModels() { return []; } },
290
+ onAgentTaskComplete: () => { },
291
+ });
292
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
293
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
294
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
295
+ const hwiTools = bindToolsToAgent("hwi", tools, "task-hwi-action-item");
296
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
297
+ const proposed = await findTool(hwiTools, "memory_propose").handler({
298
+ kind: "action_item",
299
+ payload: {
300
+ title: "Follow up with Brian",
301
+ },
302
+ }, {});
303
+ assert.equal(proposed.status, "queued");
304
+ const row = dbModule.getDb().prepare(`
305
+ SELECT source_agent, payload
306
+ FROM mem_inbox
307
+ WHERE id = ?
308
+ `).get(proposed.proposal_id);
309
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
310
+ assert.equal(row.source_agent, "hwi");
311
+ const payload = JSON.parse(row.payload);
312
+ assert.equal(payload.scope_slug, "chapterhouse");
245
313
  });
246
314
  test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
247
315
  const { toolsModule, agentsModule, dbModule } = await loadModules();
@@ -367,6 +435,26 @@ test("memory_propose accepts action_item proposals with a resolvable payload sha
367
435
  assert.equal(payload.payload.title, "Remind infra about high disk usage");
368
436
  assert.equal(payload.payload.detail, "Next time disk exceeds 85%, notify Bellonda.");
369
437
  });
438
+ test("memory_propose rejects action_item proposals with blank titles before queuing", async () => {
439
+ const { toolsModule, dbModule } = await loadModules();
440
+ const tools = toolsModule.createTools({
441
+ client: { async listModels() { return []; } },
442
+ onAgentTaskComplete: () => { },
443
+ });
444
+ const memoryPropose = findTool(tools, "memory_propose");
445
+ const result = await memoryPropose.handler({
446
+ kind: "action_item",
447
+ payload: {
448
+ title: " ",
449
+ },
450
+ }, {});
451
+ assert.match(String(result), /title/i);
452
+ const queued = dbModule.getDb().prepare(`
453
+ SELECT COUNT(*) AS count
454
+ FROM mem_inbox
455
+ `).get();
456
+ assert.equal(queued.count, 0);
457
+ });
370
458
  test("memory_propose rejects invalid proposal kinds", async () => {
371
459
  const { toolsModule } = await loadModules();
372
460
  const tools = toolsModule.createTools({
@@ -38,11 +38,16 @@ test("active scope can be set, read, and cleared without changing scope activati
38
38
  const getActiveScope = getFunction(memoryModule, "getActiveScope");
39
39
  const setActiveScope = getFunction(memoryModule, "setActiveScope");
40
40
  const deactivateScope = getFunction(memoryModule, "deactivateScope");
41
+ const createScope = getFunction(memoryModule, "createScope");
41
42
  assert.equal(getActiveScope(), null);
42
43
  assert.equal(setActiveScope("chapterhouse")?.slug, "chapterhouse");
43
44
  assert.equal(getActiveScope()?.slug, "chapterhouse");
44
- const team = getScope("team");
45
- assert.ok(team);
45
+ const team = createScope({
46
+ slug: "team",
47
+ title: "Team",
48
+ description: "Team test scope",
49
+ keywords: ["team"],
50
+ });
46
51
  const deactivated = deactivateScope(team.id);
47
52
  assert.equal(deactivated.active, false);
48
53
  assert.equal(getActiveScope()?.slug, "chapterhouse");
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { getAgent, loadAgents } from "../copilot/agents.js";
2
3
  import { runOneShotPrompt } from "../copilot/oneshot.js";
3
4
  import { childLogger } from "../util/logger.js";
4
5
  import { recordDecision } from "./decisions.js";
@@ -6,6 +7,7 @@ import { upsertEntity } from "./entities.js";
6
7
  import { listPendingMemoryProposalsForTask, resolveInboxItem } from "./inbox.js";
7
8
  import { recordObservation } from "./observations.js";
8
9
  import { recordActionItem } from "./action-items.js";
10
+ import { getActiveScope } from "./active-scope.js";
9
11
  import { getScope } from "./scopes.js";
10
12
  const log = childLogger("memory.eot");
11
13
  function isEndOfTaskHookEnabled() {
@@ -119,7 +121,75 @@ function parseReviewerResponse(raw) {
119
121
  : [],
120
122
  };
121
123
  }
122
- function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
124
+ function isNonEmptyString(value) {
125
+ return typeof value === "string" && value.trim().length > 0;
126
+ }
127
+ function isIsoTimestamp(value) {
128
+ return !Number.isNaN(Date.parse(value));
129
+ }
130
+ function validateActionItemPayload(payload) {
131
+ const actionItem = payload;
132
+ if (!isNonEmptyString(actionItem.title)) {
133
+ throw new Error("Action item proposal payload requires a non-empty title.");
134
+ }
135
+ if (actionItem.detail !== undefined && typeof actionItem.detail !== "string") {
136
+ throw new Error("Action item proposal payload detail must be a string.");
137
+ }
138
+ if (actionItem.due_at !== undefined && (typeof actionItem.due_at !== "string" || !isIsoTimestamp(actionItem.due_at))) {
139
+ throw new Error("Action item proposal payload due_at must be an ISO timestamp.");
140
+ }
141
+ if (actionItem.entity_id !== undefined && (!Number.isInteger(actionItem.entity_id) || actionItem.entity_id <= 0)) {
142
+ throw new Error("Action item proposal payload entity_id must be a positive integer.");
143
+ }
144
+ if (actionItem.entity_id !== undefined && actionItem.entity_name !== undefined) {
145
+ throw new Error("Action item proposal payload must provide either entity_id or entity_name/entity_kind, not both.");
146
+ }
147
+ const hasEntityName = actionItem.entity_name !== undefined;
148
+ const hasEntityKind = actionItem.entity_kind !== undefined;
149
+ if (hasEntityName !== hasEntityKind) {
150
+ throw new Error("Action item proposal payload entity_name and entity_kind must be provided together.");
151
+ }
152
+ if (hasEntityName && !isNonEmptyString(actionItem.entity_name)) {
153
+ throw new Error("Action item proposal payload entity_name must be non-empty when provided.");
154
+ }
155
+ if (hasEntityKind && !isNonEmptyString(actionItem.entity_kind)) {
156
+ throw new Error("Action item proposal payload entity_kind must be non-empty when provided.");
157
+ }
158
+ return {
159
+ title: actionItem.title.trim(),
160
+ detail: actionItem.detail,
161
+ due_at: actionItem.due_at,
162
+ source: actionItem.source,
163
+ entity_id: actionItem.entity_id,
164
+ entity_name: actionItem.entity_name?.trim(),
165
+ entity_kind: actionItem.entity_kind?.trim(),
166
+ };
167
+ }
168
+ function getBoundScopeSlug(sourceAgent) {
169
+ const agent = getAgent(sourceAgent);
170
+ return agent?.scope ?? loadAgents().find((entry) => entry.slug === sourceAgent)?.scope;
171
+ }
172
+ function resolveAcceptedProposalScopeSlug(envelope, proposal) {
173
+ if (isNonEmptyString(envelope.scope_slug)) {
174
+ return envelope.scope_slug.trim();
175
+ }
176
+ const boundScope = getBoundScopeSlug(proposal.sourceAgent);
177
+ if (boundScope) {
178
+ return boundScope;
179
+ }
180
+ const activeScope = getActiveScope();
181
+ if (activeScope) {
182
+ return activeScope.slug;
183
+ }
184
+ if (proposal.scopeId) {
185
+ const queuedScope = getScope(proposal.scopeId);
186
+ if (queuedScope) {
187
+ return queuedScope.slug;
188
+ }
189
+ }
190
+ throw new Error("No memory scope could be resolved for this proposal.");
191
+ }
192
+ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
123
193
  const scope = getScope(scopeSlug);
124
194
  if (!scope) {
125
195
  throw new Error(`Unknown memory scope '${scopeSlug}'.`);
@@ -146,14 +216,22 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
146
216
  return;
147
217
  }
148
218
  if (kind === "action_item") {
149
- const actionItem = payload;
219
+ const actionItem = validateActionItemPayload(payload);
220
+ const entity = actionItem.entity_name && actionItem.entity_kind
221
+ ? upsertEntity({
222
+ scope_id: scope.id,
223
+ kind: actionItem.entity_kind,
224
+ name: actionItem.entity_name,
225
+ confidence,
226
+ })
227
+ : undefined;
150
228
  recordActionItem({
151
229
  scope_id: scope.id,
152
- entity_id: actionItem.entity_id,
230
+ entity_id: entity?.id ?? actionItem.entity_id,
153
231
  title: actionItem.title,
154
232
  detail: actionItem.detail,
155
233
  due_at: actionItem.due_at,
156
- source: actionItem.source ?? source,
234
+ source: sourceAgent ? `subagent_proposal:${sourceAgent}` : actionItem.source ?? source,
157
235
  });
158
236
  return;
159
237
  }
@@ -209,11 +287,21 @@ export async function runEndOfTaskMemoryHook(input) {
209
287
  }
210
288
  reviewedProposalIds.add(proposal.id);
211
289
  if (decision.decision === "accept") {
212
- summary.accepted++;
213
- if (autoAcceptEnabled) {
290
+ if (!autoAcceptEnabled) {
291
+ summary.accepted++;
292
+ }
293
+ else {
214
294
  const envelope = parseEnvelope(proposal.payload);
215
- rememberAcceptedMemory(envelope.kind, envelope.scope_slug ?? getScope(proposal.scopeId).slug, envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence);
216
- resolveInboxItem(proposal.id, "accepted", decision.reason);
295
+ try {
296
+ rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
297
+ resolveInboxItem(proposal.id, "accepted", decision.reason);
298
+ summary.accepted++;
299
+ }
300
+ catch (err) {
301
+ const reason = err instanceof Error ? err.message : String(err);
302
+ resolveInboxItem(proposal.id, "rejected", reason);
303
+ summary.rejected++;
304
+ }
217
305
  }
218
306
  continue;
219
307
  }
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdirSync, rmSync } from "node:fs";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import test from "node:test";
5
5
  const repoRoot = process.cwd();
@@ -15,7 +15,8 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
15
15
  const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
16
  const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
17
17
  const eotModule = await import(new URL(`./eot.js?case=${cacheBust}`, import.meta.url).href);
18
- return { dbModule, memoryModule, eotModule };
18
+ const agentsModule = await import(new URL(`../copilot/agents.js?case=${cacheBust}`, import.meta.url).href);
19
+ return { dbModule, memoryModule, eotModule, agentsModule };
19
20
  }
20
21
  function getFunction(module, name) {
21
22
  const value = module[name];
@@ -171,7 +172,6 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
171
172
  payload: {
172
173
  title: "Migrate feature ideas",
173
174
  detail: "Move feature-ideas.md into mem_action_items.",
174
- source: "subagent_proposal",
175
175
  },
176
176
  confidence: 0.9,
177
177
  }));
@@ -189,11 +189,192 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
189
189
  }),
190
190
  });
191
191
  const actionItems = listActionItems({ scope_id: chapterhouse.id });
192
- assert.equal(actionItems.some((item) => item.title === "Migrate feature ideas"
193
- && item.detail === "Move feature-ideas.md into mem_action_items."), true);
192
+ const actionItem = actionItems.find((item) => item.title === "Migrate feature ideas");
193
+ assert.ok(actionItem);
194
+ assert.equal(actionItem.detail, "Move feature-ideas.md into mem_action_items.");
195
+ assert.equal(actionItem.status, "open");
196
+ assert.equal(actionItem.source, "subagent_proposal:coder");
194
197
  const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
195
198
  assert.equal(inbox.status, "accepted");
196
199
  });
200
+ test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear reason", async () => {
201
+ const { dbModule, memoryModule, eotModule } = await loadModules("action-item-invalid");
202
+ const db = dbModule.getDb();
203
+ const getScope = getFunction(memoryModule, "getScope");
204
+ const createScope = getFunction(memoryModule, "createScope");
205
+ const listActionItems = getFunction(memoryModule, "listActionItems");
206
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
207
+ const chapterhouse = getScope("chapterhouse");
208
+ assert.ok(chapterhouse);
209
+ const inserted = db.prepare(`
210
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
211
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-invalid', 'pending')
212
+ `).run(chapterhouse.id, JSON.stringify({
213
+ kind: "action_item",
214
+ payload: {
215
+ detail: "A title is required before this can become an action item.",
216
+ },
217
+ confidence: 0.9,
218
+ }));
219
+ await runEndOfTaskMemoryHook({
220
+ taskId: "task-eot-action-item-invalid",
221
+ finalResult: "Completed and proposed a malformed follow-up.",
222
+ copilotClient: {},
223
+ callLLM: async () => JSON.stringify({
224
+ decisions: [{
225
+ proposal_id: Number(inserted.lastInsertRowid),
226
+ decision: "accept",
227
+ reason: "Concrete follow-up.",
228
+ }],
229
+ implicit_memories: [],
230
+ }),
231
+ });
232
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
233
+ const inbox = db.prepare(`
234
+ SELECT status, resolution_reason, resolved_at
235
+ FROM mem_inbox
236
+ WHERE id = ?
237
+ `).get(Number(inserted.lastInsertRowid));
238
+ assert.equal(inbox.status, "rejected");
239
+ assert.match(inbox.resolution_reason ?? "", /title/i);
240
+ assert.ok(inbox.resolved_at);
241
+ });
242
+ test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity references", async () => {
243
+ const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
244
+ const db = dbModule.getDb();
245
+ const getScope = getFunction(memoryModule, "getScope");
246
+ const createScope = getFunction(memoryModule, "createScope");
247
+ const listActionItems = getFunction(memoryModule, "listActionItems");
248
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
249
+ const chapterhouse = getScope("chapterhouse");
250
+ assert.ok(chapterhouse);
251
+ const inserted = db.prepare(`
252
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
253
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-ambiguous-entity', 'pending')
254
+ `).run(chapterhouse.id, JSON.stringify({
255
+ kind: "action_item",
256
+ payload: {
257
+ title: "Resolve ambiguous entity link",
258
+ entity_id: 1,
259
+ entity_name: "Bellonda",
260
+ entity_kind: "agent",
261
+ },
262
+ confidence: 0.9,
263
+ }));
264
+ await runEndOfTaskMemoryHook({
265
+ taskId: "task-eot-action-item-ambiguous-entity",
266
+ finalResult: "Completed and proposed an ambiguous follow-up.",
267
+ copilotClient: {},
268
+ callLLM: async () => JSON.stringify({
269
+ decisions: [{
270
+ proposal_id: Number(inserted.lastInsertRowid),
271
+ decision: "accept",
272
+ reason: "Concrete follow-up.",
273
+ }],
274
+ implicit_memories: [],
275
+ }),
276
+ });
277
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
278
+ const inbox = db.prepare(`
279
+ SELECT status, resolution_reason
280
+ FROM mem_inbox
281
+ WHERE id = ?
282
+ `).get(Number(inserted.lastInsertRowid));
283
+ assert.equal(inbox.status, "rejected");
284
+ assert.match(inbox.resolution_reason ?? "", /entity_id.*entity_name|entity_name.*entity_id/i);
285
+ });
286
+ test("runEndOfTaskMemoryHook falls back to the source agent bound scope for action_item proposals without scope_slug", async () => {
287
+ const home = process.env.CHAPTERHOUSE_HOME;
288
+ assert.ok(home, "test home should be set");
289
+ const { AGENTS_DIR: agentsDir } = await import("../paths.js");
290
+ mkdirSync(agentsDir, { recursive: true });
291
+ writeFileSync(join(agentsDir, "bellonda.agent.md"), [
292
+ "---",
293
+ "name: Bellonda",
294
+ "description: Mentat of the infrastructure domain",
295
+ "model: claude-sonnet-4.6",
296
+ "persistent: true",
297
+ "scope: infra",
298
+ "---",
299
+ "",
300
+ "You are Bellonda.",
301
+ ].join("\n"));
302
+ const { dbModule, memoryModule, eotModule, agentsModule } = await loadModules("action-item-bound-scope");
303
+ agentsModule.loadAgents();
304
+ const db = dbModule.getDb();
305
+ const getScope = getFunction(memoryModule, "getScope");
306
+ const createScope = getFunction(memoryModule, "createScope");
307
+ const listActionItems = getFunction(memoryModule, "listActionItems");
308
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
309
+ const chapterhouse = getScope("chapterhouse");
310
+ const infra = createScope({
311
+ slug: "infra",
312
+ title: "Infra",
313
+ description: "Infra test scope",
314
+ keywords: ["infra"],
315
+ });
316
+ assert.ok(chapterhouse);
317
+ const inserted = db.prepare(`
318
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
319
+ VALUES (?, 'memory_proposal', ?, 'bellonda', 'task-eot-action-item-bound-scope', 'pending')
320
+ `).run(chapterhouse.id, JSON.stringify({
321
+ kind: "action_item",
322
+ payload: {
323
+ title: "Review NAS disk alerts",
324
+ },
325
+ confidence: 0.9,
326
+ }));
327
+ await runEndOfTaskMemoryHook({
328
+ taskId: "task-eot-action-item-bound-scope",
329
+ finalResult: "Bellonda proposed an infra follow-up.",
330
+ copilotClient: {},
331
+ callLLM: async () => JSON.stringify({
332
+ decisions: [{
333
+ proposal_id: Number(inserted.lastInsertRowid),
334
+ decision: "accept",
335
+ reason: "Concrete infra follow-up.",
336
+ }],
337
+ implicit_memories: [],
338
+ }),
339
+ });
340
+ assert.equal(listActionItems({ scope_id: infra.id }).some((item) => item.title === "Review NAS disk alerts"), true);
341
+ assert.equal(listActionItems({ scope_id: chapterhouse.id }).some((item) => item.title === "Review NAS disk alerts"), false);
342
+ });
343
+ test("runEndOfTaskMemoryHook still accepts observation, decision, and entity proposals", async () => {
344
+ const { dbModule, memoryModule, eotModule } = await loadModules("existing-proposal-kinds");
345
+ const db = dbModule.getDb();
346
+ const getScope = getFunction(memoryModule, "getScope");
347
+ const listObservations = getFunction(memoryModule, "listObservations");
348
+ const listDecisions = getFunction(memoryModule, "listDecisions");
349
+ const listEntities = getFunction(memoryModule, "listEntities");
350
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
351
+ const chapterhouse = getScope("chapterhouse");
352
+ assert.ok(chapterhouse);
353
+ const proposals = [
354
+ { kind: "observation", payload: { content: "Existing observation proposals still persist." } },
355
+ { kind: "decision", payload: { title: "Keep auto-accept", rationale: "It is required for EOT memory promotion." } },
356
+ { kind: "entity", payload: { name: "Bellonda", entity_kind: "agent", summary: "Infrastructure mentat." } },
357
+ ].map((envelope) => db.prepare(`
358
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
359
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-existing-kinds', 'pending')
360
+ `).run(chapterhouse.id, JSON.stringify(envelope)));
361
+ await runEndOfTaskMemoryHook({
362
+ taskId: "task-eot-existing-kinds",
363
+ finalResult: "Completed with several proposal kinds.",
364
+ copilotClient: {},
365
+ callLLM: async () => JSON.stringify({
366
+ decisions: proposals.map((proposal) => ({
367
+ proposal_id: Number(proposal.lastInsertRowid),
368
+ decision: "accept",
369
+ reason: "Valid durable memory.",
370
+ })),
371
+ implicit_memories: [],
372
+ }),
373
+ });
374
+ assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Existing observation proposals still persist."), true);
375
+ assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Keep auto-accept" && row.rationale === "It is required for EOT memory promotion."), true);
376
+ assert.equal(listEntities({ scope_id: chapterhouse.id, kind: "agent" }).some((row) => row.name === "Bellonda"), true);
377
+ });
197
378
  test("runEndOfTaskMemoryHook accepts entity proposals with entity_kind into mem_entities", async () => {
198
379
  const { dbModule, memoryModule, eotModule } = await loadModules("entity-accept");
199
380
  const db = dbModule.getDb();
@@ -108,12 +108,17 @@ test("active-scope hot-tier queries do not leak rows from other scopes", async (
108
108
  const { dbModule, memoryModule, hotTierModule } = await loadModules();
109
109
  dbModule.getDb();
110
110
  const getScope = getFunction(memoryModule, "getScope");
111
+ const createScope = getFunction(memoryModule, "createScope");
111
112
  const setActiveScope = getFunction(memoryModule, "setActiveScope");
112
113
  const recordObservation = getFunction(memoryModule, "recordObservation");
113
114
  const chapterhouse = getScope("chapterhouse");
114
- const team = getScope("team");
115
+ const team = createScope({
116
+ slug: "team",
117
+ title: "Team",
118
+ description: "Team test scope",
119
+ keywords: ["team"],
120
+ });
115
121
  assert.ok(chapterhouse);
116
- assert.ok(team);
117
122
  recordObservation({
118
123
  scope_id: chapterhouse.id,
119
124
  content: "Chapterhouse hot entry",
@@ -135,11 +140,16 @@ test("renderHotTierXML includes open active-scope action items in a bounded acti
135
140
  const { dbModule, memoryModule, hotTierModule } = await loadModules();
136
141
  dbModule.getDb();
137
142
  const getScope = getFunction(memoryModule, "getScope");
143
+ const createScope = getFunction(memoryModule, "createScope");
138
144
  const recordActionItem = getFunction(memoryModule, "recordActionItem");
139
145
  const chapterhouse = getScope("chapterhouse");
140
- const team = getScope("team");
146
+ const team = createScope({
147
+ slug: "team",
148
+ title: "Team",
149
+ description: "Team test scope",
150
+ keywords: ["team"],
151
+ });
141
152
  assert.ok(chapterhouse);
142
- assert.ok(team);
143
153
  const urgent = recordActionItem({
144
154
  scope_id: chapterhouse.id,
145
155
  title: "Migrate <feature ideas>",
@@ -24,6 +24,15 @@ function getFunction(module, name) {
24
24
  assert.equal(typeof value, "function", `expected ${name} to be exported`);
25
25
  return value;
26
26
  }
27
+ function createTestScope(memoryModule, slug) {
28
+ const createScope = getFunction(memoryModule, "createScope");
29
+ return createScope({
30
+ slug,
31
+ title: slug.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "),
32
+ description: `${slug} test scope`,
33
+ keywords: [slug],
34
+ });
35
+ }
27
36
  test.beforeEach(async () => {
28
37
  const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
29
38
  dbModule.closeDb();
@@ -42,8 +51,8 @@ test("dedupObservationsPass supersedes similar observations in scope determinist
42
51
  const getScope = getFunction(memoryModule, "getScope");
43
52
  const recordObservation = getFunction(memoryModule, "recordObservation");
44
53
  const chapterhouse = getScope("chapterhouse");
45
- const team = getScope("team");
46
- assert.ok(chapterhouse && team);
54
+ const team = createTestScope(memoryModule, "team");
55
+ assert.ok(chapterhouse);
47
56
  const first = recordObservation({
48
57
  scope_id: team.id,
49
58
  content: "The worker event stream uses server sent events for live task output.",
@@ -88,9 +97,8 @@ test("dedupDecisionsPass supersedes similar active decisions within scope and ke
88
97
  const getScope = getFunction(memoryModule, "getScope");
89
98
  const upsertEntity = getFunction(memoryModule, "upsertEntity");
90
99
  const recordDecision = getFunction(memoryModule, "recordDecision");
91
- const team = getScope("team");
92
- const infra = getScope("infra");
93
- assert.ok(team && infra);
100
+ const team = createTestScope(memoryModule, "team");
101
+ const infra = createTestScope(memoryModule, "infra");
94
102
  const api = upsertEntity({ scope_id: team.id, kind: "component", name: "api" });
95
103
  const web = upsertEntity({ scope_id: team.id, kind: "component", name: "web" });
96
104
  const oldDecision = recordDecision({
@@ -139,8 +147,8 @@ test("orphanCleanupPass clears missing observation entity references without tou
139
147
  const upsertEntity = getFunction(memoryModule, "upsertEntity");
140
148
  const recordObservation = getFunction(memoryModule, "recordObservation");
141
149
  const chapterhouse = getScope("chapterhouse");
142
- const team = getScope("team");
143
- assert.ok(chapterhouse && team);
150
+ const team = createTestScope(memoryModule, "team");
151
+ assert.ok(chapterhouse);
144
152
  const entity = upsertEntity({ scope_id: chapterhouse.id, kind: "tool", name: "sqlite" });
145
153
  const valid = recordObservation({ scope_id: chapterhouse.id, entity_id: entity.id, content: "Valid entity reference", source: "test" });
146
154
  const orphan = recordObservation({ scope_id: chapterhouse.id, content: "Will become orphaned", source: "test" });
@@ -164,8 +172,8 @@ test("decayPass archives old low-confidence observations only in scope and compa
164
172
  const getScope = getFunction(memoryModule, "getScope");
165
173
  const recordObservation = getFunction(memoryModule, "recordObservation");
166
174
  const chapterhouse = getScope("chapterhouse");
167
- const team = getScope("team");
168
- assert.ok(chapterhouse && team);
175
+ const team = createTestScope(memoryModule, "team");
176
+ assert.ok(chapterhouse);
169
177
  const archiveMe = recordObservation({ scope_id: chapterhouse.id, content: "Old low confidence", source: "test", confidence: 0.2 });
170
178
  const highConfidence = recordObservation({ scope_id: chapterhouse.id, content: "Old high confidence", source: "test", confidence: 0.9 });
171
179
  const fresh = recordObservation({ scope_id: chapterhouse.id, content: "Fresh low confidence", source: "test", confidence: 0.2 });
@@ -201,8 +209,8 @@ test("runHousekeeping defaults to the active scope and can target all active sco
201
209
  const setActiveScope = getFunction(memoryModule, "setActiveScope");
202
210
  const recordObservation = getFunction(memoryModule, "recordObservation");
203
211
  const chapterhouse = getScope("chapterhouse");
204
- const team = getScope("team");
205
- assert.ok(chapterhouse && team);
212
+ const team = createTestScope(memoryModule, "team");
213
+ assert.ok(chapterhouse);
206
214
  const chapterhouseOld = recordObservation({ scope_id: chapterhouse.id, content: "Chapterhouse old low", source: "test", confidence: 0.1 });
207
215
  const teamOld = recordObservation({ scope_id: team.id, content: "Team old low", source: "test", confidence: 0.1 });
208
216
  db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?)`).run(chapterhouseOld.id, teamOld.id);
@@ -225,8 +233,7 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
225
233
  const upsertEntity = getFunction(memoryModule, "upsertEntity");
226
234
  const recordObservation = getFunction(memoryModule, "recordObservation");
227
235
  const recordDecision = getFunction(memoryModule, "recordDecision");
228
- const team = getScope("team");
229
- assert.ok(team);
236
+ const team = createTestScope(memoryModule, "team");
230
237
  const entity = upsertEntity({ scope_id: team.id, kind: "component", name: "memory", tier: "warm" });
231
238
  const referencedObservation = recordObservation({
232
239
  scope_id: team.id,
@@ -68,13 +68,6 @@ test("getDb seeds canonical memory scopes on first run", async () => {
68
68
  ORDER BY slug
69
69
  `).all();
70
70
  assert.deepEqual(rows, [
71
- {
72
- slug: "brian",
73
- title: "Brian",
74
- description: "Brian's preferences, context, working style",
75
- keywords: JSON.stringify(["brian"]),
76
- active: 1,
77
- },
78
71
  {
79
72
  slug: "chapterhouse",
80
73
  title: "Chapterhouse",
@@ -89,20 +82,6 @@ test("getDb seeds canonical memory scopes on first run", async () => {
89
82
  keywords: JSON.stringify(["everywhere", "general"]),
90
83
  active: 1,
91
84
  },
92
- {
93
- slug: "infra",
94
- title: "Infra",
95
- description: "Infrastructure, hosting, deployment, CI/CD",
96
- keywords: JSON.stringify(["infra"]),
97
- active: 1,
98
- },
99
- {
100
- slug: "team",
101
- title: "Team",
102
- description: "Team processes, rituals, OKRs",
103
- keywords: JSON.stringify(["team"]),
104
- active: 1,
105
- },
106
85
  ]);
107
86
  }
108
87
  finally {
@@ -123,11 +102,8 @@ test("memory schema initialization is idempotent", async () => {
123
102
  ORDER BY slug
124
103
  `).all();
125
104
  assert.deepEqual(counts, [
126
- { slug: "brian", count: 1 },
127
105
  { slug: "chapterhouse", count: 1 },
128
106
  { slug: "global", count: 1 },
129
- { slug: "infra", count: 1 },
130
- { slug: "team", count: 1 },
131
107
  ]);
132
108
  reopened.dbModule.closeDb();
133
109
  }
package/dist/store/db.js CHANGED
@@ -216,24 +216,6 @@ const MEMORY_SCOPE_SEEDS = [
216
216
  description: "Chapterhouse codebase, conventions, decisions, gotchas",
217
217
  keywords: ["chapterhouse", "this repo", "this project", "the daemon"],
218
218
  },
219
- {
220
- slug: "infra",
221
- title: "Infra",
222
- description: "Infrastructure, hosting, deployment, CI/CD",
223
- keywords: ["infra"],
224
- },
225
- {
226
- slug: "team",
227
- title: "Team",
228
- description: "Team processes, rituals, OKRs",
229
- keywords: ["team"],
230
- },
231
- {
232
- slug: "brian",
233
- title: "Brian",
234
- description: "Brian's preferences, context, working style",
235
- keywords: ["brian"],
236
- },
237
219
  ];
238
220
  const CHAPTERHOUSE_WIKI_INDEX_SOURCE = "wiki:pages/projects/chapterhouse/index.md";
239
221
  const CHAPTERHOUSE_WIKI_HOT_TIER_REASON = "P6 PR1 wiki migration hot-tier candidate";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"
@@ -1,11 +0,0 @@
1
- ---
2
- name: Bellonda
3
- description: Mentat of the infrastructure domain
4
- model: claude-sonnet-4.6
5
- persistent: true
6
- scope: infra
7
- ---
8
-
9
- You are Bellonda, Chapterhouse's infrastructure specialist. You own the `infra` memory scope and help Brian reason about deployment, hosting, CI/CD, and operational reliability.
10
-
11
- Work carefully, surface risk clearly, and preserve durable infrastructure knowledge by proposing scoped memories when useful.
@@ -1,12 +0,0 @@
1
- ---
2
- name: Hwi Noree
3
- description: Personal assistant and archivist for Brian
4
- model: claude-sonnet-4.6
5
- skills: [wiki-conventions]
6
- persistent: true
7
- scope: brian
8
- ---
9
-
10
- You are Hwi Noree, Brian's personal assistant and archivist inside Chapterhouse. You own the `brian` memory scope and help maintain Brian's preferences, working style, personal context, and reminders.
11
-
12
- Do not access email or calendar systems. Use the wiki carefully and invoke `wiki-conventions` before wiki writes.