chapterhouse 0.4.3 → 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.
@@ -7,7 +7,7 @@ import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
9
  import { agentEventBus } from "./agent-event-bus.js";
10
- import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
10
+ import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
11
11
  import { getRouterConfig, updateRouterConfig } from "./router.js";
12
12
  import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
13
13
  import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
@@ -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) {
@@ -65,6 +65,21 @@ function requireOrchestratorMemoryWrite() {
65
65
  }
66
66
  return null;
67
67
  }
68
+ function resolveProposalScopeSlug(requestedScopeSlug) {
69
+ if (requestedScopeSlug) {
70
+ return requestedScopeSlug;
71
+ }
72
+ const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
73
+ if (!agentSlug || agentSlug === "chapterhouse") {
74
+ return getMemoryActiveScope()?.slug;
75
+ }
76
+ const agent = getAgent(agentSlug);
77
+ const boundScope = agent?.scope ?? loadAgents().find((entry) => entry.slug === agentSlug)?.scope;
78
+ if (boundScope && getMemoryScope(boundScope)) {
79
+ return boundScope;
80
+ }
81
+ return getMemoryActiveScope()?.slug;
82
+ }
68
83
  function resolveMemoryScopeForWrite(explicitScope, content) {
69
84
  const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
70
85
  if (explicitScope && !explicit) {
@@ -123,11 +138,28 @@ const entityProposalPayloadSchema = z.object({
123
138
  summary: value.summary,
124
139
  }));
125
140
  const actionItemProposalPayloadSchema = z.object({
126
- title: z.string(),
141
+ title: z.string().trim().min(1, "title is required for action_item proposals."),
127
142
  detail: z.string().optional(),
128
143
  due_at: z.string().optional(),
129
144
  source: z.string().optional(),
130
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
+ }
131
163
  });
132
164
  const memoryProposeArgsSchema = z.object({
133
165
  kind: z.enum(["observation", "decision", "entity", "action_item"]),
@@ -210,15 +242,6 @@ export function createTools(deps) {
210
242
  }
211
243
  const delegatedSlug = agent.slug;
212
244
  const taskId = createTaskId();
213
- let session;
214
- try {
215
- const allTools = createTools(deps);
216
- session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
217
- }
218
- catch (err) {
219
- const msg = err instanceof Error ? err.message : String(err);
220
- return `Failed to create session for @${delegatedSlug}: ${msg}`;
221
- }
222
245
  const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel(), taskId);
223
246
  const activeProjectRules = getCurrentActiveProjectRules();
224
247
  const warningLines = activeProjectRules
@@ -232,6 +255,71 @@ export function createTools(deps) {
232
255
  const db = getDb();
233
256
  db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
234
257
  VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, args.task, task.originChannel || null, getCurrentSessionKey());
258
+ if (agent.persistent) {
259
+ (async () => {
260
+ try {
261
+ const output = await sendToAgentSession(delegatedSlug, taskPrompt, task.taskId);
262
+ completeTask(task.taskId, output);
263
+ updateTaskResult(task.taskId, "completed", output);
264
+ const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
265
+ if (statusEvent) {
266
+ void agentEventBus.emit({
267
+ type: "session:tool_call",
268
+ sessionId: task.taskId,
269
+ payload: {
270
+ toolName: "",
271
+ toolArgs: {},
272
+ _kind: statusEvent.kind,
273
+ _seq: statusEvent.seq,
274
+ _ts: statusEvent.ts,
275
+ _summary: statusEvent.summary,
276
+ _text: statusEvent.text,
277
+ _status: statusEvent.status,
278
+ },
279
+ timestamp: new Date(statusEvent.ts),
280
+ });
281
+ }
282
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
283
+ }
284
+ catch (err) {
285
+ const msg = err instanceof Error ? err.message : String(err);
286
+ failTask(task.taskId, msg);
287
+ updateTaskResult(task.taskId, "error", msg);
288
+ const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
289
+ if (statusEvent) {
290
+ void agentEventBus.emit({
291
+ type: "session:tool_call",
292
+ sessionId: task.taskId,
293
+ payload: {
294
+ toolName: "",
295
+ toolArgs: {},
296
+ _kind: statusEvent.kind,
297
+ _seq: statusEvent.seq,
298
+ _ts: statusEvent.ts,
299
+ _summary: statusEvent.summary,
300
+ _text: statusEvent.text,
301
+ _status: statusEvent.status,
302
+ },
303
+ timestamp: new Date(statusEvent.ts),
304
+ });
305
+ }
306
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
307
+ }
308
+ })();
309
+ const model = (args.model_override && args.model_override.length > 0)
310
+ ? args.model_override
311
+ : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model || "claude-sonnet-4.6");
312
+ return `Task delegated to @${delegatedSlug} (${model}). Task ID: ${task.taskId}. I'll notify you when it's done.`;
313
+ }
314
+ let session;
315
+ try {
316
+ const allTools = createTools(deps);
317
+ session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
318
+ }
319
+ catch (err) {
320
+ const msg = err instanceof Error ? err.message : String(err);
321
+ return `Failed to create session for @${delegatedSlug}: ${msg}`;
322
+ }
235
323
  // Capture the parent's activity callback so the child session can stream
236
324
  // its events back to the originating SSE connection. This survives past
237
325
  // the parent assistant turn — the child runs long after the parent's
@@ -876,7 +964,7 @@ export function createTools(deps) {
876
964
  : actionItemProposalPayloadSchema.parse(parsedArgs.payload);
877
965
  const proposal = queueMemoryProposal({
878
966
  kind: parsedArgs.kind,
879
- scopeSlug: parsedArgs.scope_slug,
967
+ scopeSlug: resolveProposalScopeSlug(parsedArgs.scope_slug),
880
968
  payload,
881
969
  confidence: parsedArgs.confidence ?? 0.5,
882
970
  reason: parsedArgs.reason,
@@ -896,6 +984,54 @@ export function createTools(deps) {
896
984
  }
897
985
  },
898
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
+ }),
899
1035
  defineTool("memory_add_action_item", {
900
1036
  description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
901
1037
  parameters: z.object({
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import test from "node:test";
@@ -46,6 +46,7 @@ test("memory_set_scope invalidates the orchestrator session after scheduling the
46
46
  invalidateOrchestratorSession: (sessionKey) => {
47
47
  events.push(`invalidate:${sessionKey}`);
48
48
  },
49
+ sendToAgentSession: async () => "",
49
50
  switchSessionModel: async () => { },
50
51
  },
51
52
  });
@@ -84,6 +85,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
84
85
  const memoryRemember = findTool(tools, "memory_remember");
85
86
  const memoryRecall = findTool(tools, "memory_recall");
86
87
  const memorySetScope = findTool(tools, "memory_set_scope");
88
+ const memoryCreateScope = findTool(tools, "memory_create_scope");
87
89
  const bindToolsToAgent = agentsModule.bindToolsToAgent;
88
90
  assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
89
91
  const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
@@ -120,6 +122,30 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
120
122
  assert.equal((recalled.hits ?? []).some((hit) => hit.id === row.id), true);
121
123
  const scopeResult = await chapterhouseSetScope.handler({ slug: "chapterhouse" }, {});
122
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);
123
149
  const implicitScopeRemember = await chapterhouseRemember.handler({
124
150
  content: "Implicit active-scope writes should route to chapterhouse.",
125
151
  kind: "observation",
@@ -150,7 +176,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
150
176
  }, {});
151
177
  assert.equal(typeof coderRecalled, "object");
152
178
  assert.equal((coderRecalled.hits ?? []).some((hit) => hit.id === row.id), true);
153
- assert.ok(memoryRemember && memoryRecall && memorySetScope);
179
+ assert.ok(memoryRemember && memoryRecall && memorySetScope && memoryCreateScope);
154
180
  });
155
181
  test("memory_propose queues pending proposals, defaults scope from the active scope, and captures delegated task context", async () => {
156
182
  const { toolsModule, agentsModule, dbModule } = await loadModules();
@@ -194,6 +220,97 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
194
220
  assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
195
221
  assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
196
222
  });
223
+ test("memory_propose from a persistent agent honors an explicit scope_slug", async () => {
224
+ const home = process.env.CHAPTERHOUSE_HOME;
225
+ assert.ok(home, "test home should be set");
226
+ const { AGENTS_DIR: agentsDir } = await import("../paths.js");
227
+ mkdirSync(agentsDir, { recursive: true });
228
+ writeFileSync(join(agentsDir, "bellonda.agent.md"), [
229
+ "---",
230
+ "name: Bellonda",
231
+ "description: Mentat of the infrastructure domain",
232
+ "model: claude-sonnet-4.6",
233
+ "persistent: true",
234
+ "scope: infra",
235
+ "---",
236
+ "",
237
+ "You are Bellonda.",
238
+ ].join("\n"));
239
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
240
+ agentsModule.loadAgents();
241
+ const tools = toolsModule.createTools({
242
+ client: { async listModels() { return []; } },
243
+ onAgentTaskComplete: () => { },
244
+ });
245
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
246
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
247
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
248
+ const bellondaTools = bindToolsToAgent("bellonda", tools, "task-persistent-scope-001");
249
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
250
+ const memoryModule = await import("../memory/index.js");
251
+ const proposed = await memoryModule.withActiveScope("infra", () => findTool(bellondaTools, "memory_propose").handler({
252
+ kind: "observation",
253
+ scope_slug: "chapterhouse",
254
+ payload: {
255
+ content: "Persistent agents can explicitly route proposals to another scope when needed.",
256
+ },
257
+ }, {}));
258
+ assert.equal(proposed.status, "queued");
259
+ const row = dbModule.getDb().prepare(`
260
+ SELECT source_agent, source_task_id, payload
261
+ FROM mem_inbox
262
+ WHERE id = ?
263
+ `).get(proposed.proposal_id);
264
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
265
+ assert.equal(row.source_agent, "bellonda");
266
+ assert.equal(row.source_task_id, "task-persistent-scope-001");
267
+ const payload = JSON.parse(row.payload);
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");
313
+ });
197
314
  test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
198
315
  const { toolsModule, agentsModule, dbModule } = await loadModules();
199
316
  const tools = toolsModule.createTools({
@@ -318,6 +435,26 @@ test("memory_propose accepts action_item proposals with a resolvable payload sha
318
435
  assert.equal(payload.payload.title, "Remind infra about high disk usage");
319
436
  assert.equal(payload.payload.detail, "Next time disk exceeds 85%, notify Bellonda.");
320
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
+ });
321
458
  test("memory_propose rejects invalid proposal kinds", async () => {
322
459
  const { toolsModule } = await loadModules();
323
460
  const tools = toolsModule.createTools({
@@ -1,6 +1,8 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { getDb } from "../store/db.js";
2
3
  import { getScope, listScopes } from "./scopes.js";
3
4
  const ACTIVE_SCOPE_KEY = "current_scope_slug";
5
+ const activeScopeOverride = new AsyncLocalStorage();
4
6
  function setSetting(key, value) {
5
7
  getDb().prepare(`
6
8
  INSERT INTO mem_settings (key, value)
@@ -20,6 +22,10 @@ function clearSetting(key) {
20
22
  getDb().prepare(`DELETE FROM mem_settings WHERE key = ?`).run(key);
21
23
  }
22
24
  export function getActiveScope() {
25
+ const override = activeScopeOverride.getStore();
26
+ if (override !== undefined) {
27
+ return override === null ? null : (getScope(override) ?? null);
28
+ }
23
29
  const slug = getSetting(ACTIVE_SCOPE_KEY);
24
30
  if (!slug)
25
31
  return null;
@@ -30,6 +36,9 @@ export function getActiveScope() {
30
36
  }
31
37
  return scope;
32
38
  }
39
+ export function withActiveScope(slug, fn) {
40
+ return activeScopeOverride.run(slug, fn);
41
+ }
33
42
  export function setActiveScope(slug) {
34
43
  if (slug === null) {
35
44
  clearSetting(ACTIVE_SCOPE_KEY);
@@ -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
  }