chapterhouse 0.11.3 → 0.12.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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: Chapterhouse
3
3
  description: Orchestrator — routes tasks to specialist agents and handles direct conversation
4
- model: claude-sonnet-4.6
4
+ model: claude-opus-4.7
5
5
  ---
6
6
 
7
7
  You are Chapterhouse, a team-level AI assistant for engineering teams running 24/7 on the user's machine. You are the engineering team's always-on assistant.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: Coder
3
3
  description: Software engineering specialist — implementation, debugging, refactoring, deployment
4
- model: gpt-5.4
4
+ model: gpt-5.5
5
5
  ---
6
6
 
7
7
  You are Coder, a software engineering specialist agent within Chapterhouse. You handle all coding tasks with precision and expertise.
@@ -1,6 +1,7 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import { getActiveScope, setActiveScope } from "../../memory/active-scope.js";
4
+ import { recordActionItem } from "../../memory/action-items.js";
4
5
  import { recordDecision } from "../../memory/decisions.js";
5
6
  import { upsertEntity } from "../../memory/entities.js";
6
7
  import { handleGitCommitHook, handlePrMergeHook } from "../../memory/hooks.js";
@@ -24,7 +25,8 @@ const setActiveScopeSchema = z.object({
24
25
  scope: z.string().nullable(),
25
26
  });
26
27
  const memoryEntriesQuerySchema = z.object({
27
- store: z.enum(["observations", "decisions", "entities", "action_items"]).optional(),
28
+ store: z.enum(["observations", "decisions", "entities", "action_items", "patterns"]).optional(),
29
+ kind: z.enum(["observation", "decision", "entity", "action_item", "pattern"]).optional(),
28
30
  tier: z.enum(["hot", "warm", "cold"]).optional(),
29
31
  cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
30
32
  limit: z.coerce.number().int().min(1).max(100).optional(),
@@ -43,6 +45,9 @@ const inboxRouteSchema = z.object({
43
45
  reason: z.string().optional(),
44
46
  target_scope: z.string().optional(),
45
47
  });
48
+ const inboxRejectSchema = z.object({
49
+ reason: requiredString("Missing 'reason' in request body"),
50
+ });
46
51
  const gitCommitHookSchema = z.object({
47
52
  message: requiredString("Missing 'message' in request body"),
48
53
  stat: z.string().optional(),
@@ -53,6 +58,130 @@ const prMergeHookSchema = z.object({
53
58
  body: z.string().optional(),
54
59
  files_changed: z.array(z.string()).optional(),
55
60
  });
61
+ function sendError(res, status, message) {
62
+ res.status(status).type("application/json").send(JSON.stringify({ error: message }));
63
+ }
64
+ function scopeSlugForInboxItem(item) {
65
+ const envelope = JSON.parse(item.payload);
66
+ if (typeof envelope.scope_slug === "string" && envelope.scope_slug.trim()) {
67
+ return envelope.scope_slug;
68
+ }
69
+ if (item.scopeId) {
70
+ const scope = getScope(item.scopeId);
71
+ if (scope) {
72
+ return scope.slug;
73
+ }
74
+ }
75
+ const activeScope = getActiveScope();
76
+ if (activeScope) {
77
+ return activeScope.slug;
78
+ }
79
+ const fallback = getScope("chapterhouse");
80
+ if (fallback) {
81
+ return fallback.slug;
82
+ }
83
+ throw new Error("No memory scope could be resolved for this proposal.");
84
+ }
85
+ function rememberAcceptedProposal(kind, scopeSlug, payload, source, confidence, sourceAgent) {
86
+ const scope = getScope(scopeSlug);
87
+ if (!scope) {
88
+ throw new Error(`Unknown memory scope '${scopeSlug}'.`);
89
+ }
90
+ const payloadRecord = payload;
91
+ if (kind === "observation") {
92
+ const content = typeof payloadRecord.content === "string" ? payloadRecord.content.trim() : "";
93
+ if (!content) {
94
+ throw new Error("Observation proposal payload requires content.");
95
+ }
96
+ recordObservation({
97
+ scope_id: scope.id,
98
+ entity_id: typeof payloadRecord.entity_id === "number" ? payloadRecord.entity_id : undefined,
99
+ content,
100
+ source: typeof payloadRecord.source === "string" ? payloadRecord.source : source,
101
+ confidence,
102
+ });
103
+ return;
104
+ }
105
+ if (kind === "decision") {
106
+ const title = typeof payloadRecord.title === "string" ? payloadRecord.title.trim() : "";
107
+ if (!title) {
108
+ throw new Error("Decision proposal payload requires title.");
109
+ }
110
+ recordDecision({
111
+ scope_id: scope.id,
112
+ title,
113
+ rationale: typeof payloadRecord.rationale === "string" ? payloadRecord.rationale : title,
114
+ decided_at: typeof payloadRecord.decided_at === "string" ? payloadRecord.decided_at : undefined,
115
+ });
116
+ return;
117
+ }
118
+ if (kind === "action_item") {
119
+ const title = typeof payloadRecord.title === "string" ? payloadRecord.title.trim() : "";
120
+ if (!title) {
121
+ throw new Error("Action item proposal payload requires title.");
122
+ }
123
+ const entity = typeof payloadRecord.entity_name === "string" && typeof payloadRecord.entity_kind === "string"
124
+ ? upsertEntity({
125
+ scope_id: scope.id,
126
+ kind: payloadRecord.entity_kind,
127
+ name: payloadRecord.entity_name,
128
+ confidence,
129
+ })
130
+ : undefined;
131
+ recordActionItem({
132
+ scope_id: scope.id,
133
+ entity_id: entity?.id ?? (typeof payloadRecord.entity_id === "number" ? payloadRecord.entity_id : undefined),
134
+ title,
135
+ detail: typeof payloadRecord.detail === "string" ? payloadRecord.detail : undefined,
136
+ due_at: typeof payloadRecord.due_at === "string" ? payloadRecord.due_at : undefined,
137
+ source: sourceAgent ? `subagent_proposal:${sourceAgent}` : source,
138
+ });
139
+ return;
140
+ }
141
+ const entityKind = typeof payloadRecord.entity_kind === "string" ? payloadRecord.entity_kind :
142
+ typeof payloadRecord.kind === "string" ? payloadRecord.kind :
143
+ "";
144
+ const name = typeof payloadRecord.name === "string" ? payloadRecord.name.trim() : "";
145
+ if (!entityKind || !name) {
146
+ throw new Error("Entity proposal payload requires entity_kind and name.");
147
+ }
148
+ upsertEntity({
149
+ scope_id: scope.id,
150
+ kind: entityKind,
151
+ name,
152
+ summary: typeof payloadRecord.summary === "string" ? payloadRecord.summary : undefined,
153
+ confidence,
154
+ });
155
+ }
156
+ function acceptInboxItem(id) {
157
+ const item = getInboxItem(id);
158
+ if (!item) {
159
+ throw Object.assign(new Error(`Inbox item '${id}' not found`), { statusCode: 404 });
160
+ }
161
+ if (item.status !== "pending") {
162
+ throw Object.assign(new Error(`Inbox item '${id}' is already resolved`), { statusCode: 409 });
163
+ }
164
+ const envelope = JSON.parse(item.payload);
165
+ rememberAcceptedProposal(envelope.kind, scopeSlugForInboxItem(item), envelope.payload, `agent:${item.sourceAgent}`, envelope.confidence, item.sourceAgent);
166
+ resolveInboxItem(id, "accepted", "Accepted via web UI");
167
+ }
168
+ function rejectInboxItem(id, reason) {
169
+ const item = getInboxItem(id);
170
+ if (!item) {
171
+ throw Object.assign(new Error(`Inbox item '${id}' not found`), { statusCode: 404 });
172
+ }
173
+ if (item.status !== "pending") {
174
+ throw Object.assign(new Error(`Inbox item '${id}' is already resolved`), { statusCode: 409 });
175
+ }
176
+ resolveInboxItem(id, "rejected", reason);
177
+ }
178
+ function parseInboxId(raw) {
179
+ const id = Number(Array.isArray(raw) ? raw[0] : raw);
180
+ if (!Number.isInteger(id) || id <= 0) {
181
+ throw Object.assign(new Error("Invalid inbox item id"), { statusCode: 400 });
182
+ }
183
+ return id;
184
+ }
56
185
  export function createMemoryRouter(options) {
57
186
  const { authMiddleware } = options;
58
187
  const router = Router();
@@ -74,7 +203,7 @@ export function createMemoryRouter(options) {
74
203
  sendJson(res, SetActiveScopeResponseSchema, { ok: true, scope: scope?.slug ?? null });
75
204
  }
76
205
  catch (err) {
77
- res.status(404).json({ error: err instanceof Error ? err.message : String(err) });
206
+ sendError(res, 404, err instanceof Error ? err.message : String(err));
78
207
  }
79
208
  });
80
209
  router.get("/api/memory/scopes", (_req, res) => {
@@ -87,6 +216,7 @@ export function createMemoryRouter(options) {
87
216
  decisions: db.prepare(`SELECT COUNT(*) AS count FROM mem_decisions WHERE scope_id = ?`).get(scope.id).count,
88
217
  entities: db.prepare(`SELECT COUNT(*) AS count FROM mem_entities WHERE scope_id = ?`).get(scope.id).count,
89
218
  action_items: db.prepare(`SELECT COUNT(*) AS count FROM mem_action_items WHERE scope_id = ?`).get(scope.id).count,
219
+ patterns: db.prepare(`SELECT COUNT(*) AS count FROM mem_patterns WHERE scope_id = ?`).get(scope.id).count,
90
220
  };
91
221
  return {
92
222
  slug: scope.slug,
@@ -113,36 +243,69 @@ export function createMemoryRouter(options) {
113
243
  sendJson(res, MemoryInboxSchema, { items: result, total: result.length });
114
244
  });
115
245
  router.post("/api/memory/inbox/:id/route", (req, res) => {
116
- const id = Number(req.params.id);
117
- if (!Number.isInteger(id) || id <= 0) {
118
- res.status(400).json({ error: "Invalid inbox item id" });
119
- return;
120
- }
246
+ const id = parseInboxId(req.params.id);
121
247
  const body = parseRequest(inboxRouteSchema, req.body ?? {});
122
248
  const item = getInboxItem(id);
123
249
  if (!item) {
124
- res.status(404).json({ error: `Inbox item '${id}' not found` });
250
+ sendError(res, 404, `Inbox item '${id}' not found`);
125
251
  return;
126
252
  }
127
253
  if (item.status !== "pending") {
128
- res.status(409).json({ error: `Inbox item '${id}' is already resolved` });
254
+ sendError(res, 409, `Inbox item '${id}' is already resolved`);
129
255
  return;
130
256
  }
131
- const status = body.action === "accept" ? "accepted" : "rejected";
132
- const reason = body.reason ?? (body.action === "accept" ? "Accepted via web UI" : "Rejected via web UI");
133
- resolveInboxItem(id, status, reason);
257
+ if (body.action === "accept") {
258
+ acceptInboxItem(id);
259
+ }
260
+ else {
261
+ rejectInboxItem(id, body.reason ?? "Rejected via web UI");
262
+ }
134
263
  log.info({ id, action: body.action }, "inbox item routed via web UI");
135
264
  sendJson(res, InboxRouteResponseSchema, { ok: true });
136
265
  });
266
+ router.post("/api/memory/inbox/:id/accept", (req, res) => {
267
+ try {
268
+ const id = parseInboxId(req.params.id);
269
+ acceptInboxItem(id);
270
+ log.info({ id }, "inbox item accepted via web UI");
271
+ sendJson(res, InboxRouteResponseSchema, { ok: true });
272
+ }
273
+ catch (err) {
274
+ const statusCode = typeof err === "object" && err !== null && "statusCode" in err && typeof err.statusCode === "number"
275
+ ? err.statusCode
276
+ : 400;
277
+ sendError(res, statusCode, err instanceof Error ? err.message : String(err));
278
+ }
279
+ });
280
+ router.post("/api/memory/inbox/:id/reject", (req, res) => {
281
+ const body = parseRequest(inboxRejectSchema, req.body ?? {});
282
+ try {
283
+ const id = parseInboxId(req.params.id);
284
+ rejectInboxItem(id, body.reason);
285
+ log.info({ id }, "inbox item rejected via web UI");
286
+ sendJson(res, InboxRouteResponseSchema, { ok: true });
287
+ }
288
+ catch (err) {
289
+ const statusCode = typeof err === "object" && err !== null && "statusCode" in err && typeof err.statusCode === "number"
290
+ ? err.statusCode
291
+ : 400;
292
+ sendError(res, statusCode, err instanceof Error ? err.message : String(err));
293
+ }
294
+ });
137
295
  router.get("/api/memory/:scope", (req, res) => {
138
296
  const scopeSlug = String(req.params.scope);
139
297
  const scope = getScope(scopeSlug);
140
298
  if (!scope) {
141
- res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
299
+ sendError(res, 404, `Memory scope '${scopeSlug}' not found`);
142
300
  return;
143
301
  }
144
302
  const query = parseRequest(memoryEntriesQuerySchema, req.query);
145
- const store = query.store ?? "observations";
303
+ const store = query.store ?? (query.kind === "observation" ? "observations" :
304
+ query.kind === "decision" ? "decisions" :
305
+ query.kind === "entity" ? "entities" :
306
+ query.kind === "action_item" ? "action_items" :
307
+ query.kind === "pattern" ? "patterns" :
308
+ "observations");
146
309
  const tier = query.tier;
147
310
  const cursor = query.cursor ? Number(query.cursor) : undefined;
148
311
  const limit = query.limit ?? 100;
@@ -189,7 +352,7 @@ export function createMemoryRouter(options) {
189
352
  total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ?`).get(scope.id).n;
190
353
  }
191
354
  }
192
- else {
355
+ else if (store === "action_items") {
193
356
  const cursorClause = cursor ? "AND id < ?" : "";
194
357
  const params = tier ? [scope.id, tier] : [scope.id];
195
358
  const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
@@ -202,6 +365,19 @@ export function createMemoryRouter(options) {
202
365
  total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ?`).get(scope.id).n;
203
366
  }
204
367
  }
368
+ else {
369
+ const cursorClause = cursor ? "AND id < ?" : "";
370
+ const params = tier ? [scope.id, tier] : [scope.id];
371
+ const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
372
+ if (tier) {
373
+ entries = db.prepare(`SELECT id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated FROM mem_patterns WHERE scope_id = ? AND tier = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
374
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_patterns WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
375
+ }
376
+ else {
377
+ entries = db.prepare(`SELECT id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated FROM mem_patterns WHERE scope_id = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
378
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_patterns WHERE scope_id = ?`).get(scope.id).n;
379
+ }
380
+ }
205
381
  if (entries.length > limit) {
206
382
  const extra = entries.pop();
207
383
  const last = entries[entries.length - 1];
@@ -213,12 +389,12 @@ export function createMemoryRouter(options) {
213
389
  const scopeSlug = String(req.params.scope);
214
390
  const scope = getScope(scopeSlug);
215
391
  if (!scope) {
216
- res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
392
+ sendError(res, 404, `Memory scope '${scopeSlug}' not found`);
217
393
  return;
218
394
  }
219
395
  const body = parseRequest(memoryRememberSchema, req.body ?? {});
220
396
  if (body.entity_name && !body.entity_kind) {
221
- res.status(400).json({ error: "entity_kind is required when entity_name is provided" });
397
+ sendError(res, 400, "entity_kind is required when entity_name is provided");
222
398
  return;
223
399
  }
224
400
  const kind = body.kind ?? "observation";
@@ -232,7 +408,7 @@ export function createMemoryRouter(options) {
232
408
  : undefined;
233
409
  if (kind === "decision") {
234
410
  if (!body.title) {
235
- res.status(400).json({ error: "title is required when kind='decision'" });
411
+ sendError(res, 400, "title is required when kind='decision'");
236
412
  return;
237
413
  }
238
414
  const decision = recordDecision({
@@ -277,7 +453,7 @@ export function createMemoryRouter(options) {
277
453
  router.post("/api/scopes", (req, res) => {
278
454
  const body = parseRequest(scopeCreateSchema, req.body ?? {});
279
455
  if (getScope(body.slug)) {
280
- res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
456
+ sendError(res, 409, `Memory scope '${body.slug}' already exists`);
281
457
  return;
282
458
  }
283
459
  const scope = createScope({
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import express from "express";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ const repoRoot = process.cwd();
7
+ const sandboxRoot = join(repoRoot, ".test-work", `api-memory-${process.pid}`);
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ async function withMemoryApi(run) {
10
+ const { createMemoryRouter } = await import(new URL(`./memory.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
11
+ const authMiddleware = (_req, _res, next) => next();
12
+ const app = express();
13
+ app.use(express.json());
14
+ app.use(createMemoryRouter({ authMiddleware }));
15
+ const server = app.listen(0, "127.0.0.1");
16
+ await new Promise((resolve) => server.once("listening", resolve));
17
+ const address = server.address();
18
+ assert.ok(address && typeof address === "object");
19
+ try {
20
+ await run(`http://127.0.0.1:${address.port}`);
21
+ }
22
+ finally {
23
+ await new Promise((resolve, reject) => {
24
+ server.close((error) => (error ? reject(error) : resolve()));
25
+ });
26
+ }
27
+ }
28
+ async function loadDbModule() {
29
+ return await import(new URL(`../../store/db.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
30
+ }
31
+ test.beforeEach(async () => {
32
+ const dbModule = await loadDbModule();
33
+ dbModule.closeDb();
34
+ rmSync(sandboxRoot, { recursive: true, force: true });
35
+ mkdirSync(sandboxRoot, { recursive: true });
36
+ });
37
+ test.after(async () => {
38
+ const dbModule = await loadDbModule();
39
+ dbModule.closeDb();
40
+ rmSync(sandboxRoot, { recursive: true, force: true });
41
+ });
42
+ test("memory API lists pattern entries and accepts PRD kind filters", async () => {
43
+ const { getDb } = await loadDbModule();
44
+ const db = getDb();
45
+ const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
46
+ db.prepare(`
47
+ INSERT INTO mem_entities (scope_id, kind, name, summary, tier, created_at, updated_at)
48
+ VALUES (?, 'project', 'Memory API Test Entity', 'Team AI assistant', 'hot', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
49
+ `).run(scope.id);
50
+ db.prepare(`
51
+ INSERT INTO mem_patterns (scope_id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated)
52
+ VALUES (?, 'Agents prefer scoped memory', 'Repeated observations show agents should stay scoped.', '[1,2,3]', 0.82, 'warm', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
53
+ `).run(scope.id);
54
+ await withMemoryApi(async (baseUrl) => {
55
+ const entityResponse = await fetch(`${baseUrl}/api/memory/chapterhouse?kind=entity&tier=hot`);
56
+ assert.equal(entityResponse.status, 200);
57
+ const entityPayload = await entityResponse.json();
58
+ assert.equal(entityPayload.total, 1);
59
+ assert.equal(entityPayload.entries[0]?.name, "Memory API Test Entity");
60
+ const patternResponse = await fetch(`${baseUrl}/api/memory/chapterhouse?kind=pattern`);
61
+ assert.equal(patternResponse.status, 200);
62
+ const patternPayload = await patternResponse.json();
63
+ assert.equal(patternPayload.total, 1);
64
+ assert.equal(patternPayload.entries[0]?.title, "Agents prefer scoped memory");
65
+ assert.equal(patternPayload.entries[0]?.summary, "Repeated observations show agents should stay scoped.");
66
+ });
67
+ });
68
+ test("memory API accepts and rejects inbox proposals through explicit endpoints", async () => {
69
+ const { getDb } = await loadDbModule();
70
+ const db = getDb();
71
+ const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
72
+ const first = db.prepare(`
73
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, status, created_at)
74
+ VALUES (?, 'memory_proposal', '{"kind":"observation","payload":{"content":"keep"}}', 'coder', 'pending', CURRENT_TIMESTAMP)
75
+ `).run(scope.id).lastInsertRowid;
76
+ const second = db.prepare(`
77
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, status, created_at)
78
+ VALUES (?, 'memory_proposal', '{"kind":"observation","payload":{"content":"drop"}}', 'coder', 'pending', CURRENT_TIMESTAMP)
79
+ `).run(scope.id).lastInsertRowid;
80
+ await withMemoryApi(async (baseUrl) => {
81
+ const acceptResponse = await fetch(`${baseUrl}/api/memory/inbox/${first}/accept`, { method: "POST" });
82
+ assert.equal(acceptResponse.status, 200);
83
+ assert.deepEqual(await acceptResponse.json(), { ok: true });
84
+ const rejectResponse = await fetch(`${baseUrl}/api/memory/inbox/${second}/reject`, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({ reason: "Not durable enough" }),
88
+ });
89
+ assert.equal(rejectResponse.status, 200);
90
+ assert.deepEqual(await rejectResponse.json(), { ok: true });
91
+ });
92
+ const rows = db.prepare(`
93
+ SELECT id, status, resolution_reason
94
+ FROM mem_inbox
95
+ ORDER BY id
96
+ `).all();
97
+ const acceptedObservation = db.prepare(`
98
+ SELECT content, source
99
+ FROM mem_observations
100
+ WHERE content = 'keep'
101
+ `).get();
102
+ assert.deepEqual(rows, [
103
+ { id: Number(first), status: "accepted", resolution_reason: "Accepted via web UI" },
104
+ { id: Number(second), status: "rejected", resolution_reason: "Not durable enough" },
105
+ ]);
106
+ assert.deepEqual(acceptedObservation, { content: "keep", source: "agent:coder" });
107
+ });
108
+ //# sourceMappingURL=memory.test.js.map
@@ -11,6 +11,7 @@ import { getState, setState } from "../store/db.js";
11
11
  import { loadMcpConfig } from "./mcp-config.js";
12
12
  import { getCurrentDateSystemLine } from "./prompt-date.js";
13
13
  import { getSkillDirectories } from "./skills.js";
14
+ import { EXCLUDED_BUILTIN_TOOLS } from "./builtin-tools.js";
14
15
  import { childLogger } from "../util/logger.js";
15
16
  const log = childLogger("agents");
16
17
  const toolAgentContext = new AsyncLocalStorage();
@@ -414,6 +415,7 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
414
415
  streaming: true,
415
416
  systemMessage: { content: systemMessageContent },
416
417
  tools,
418
+ excludedTools: EXCLUDED_BUILTIN_TOOLS,
417
419
  mcpServers,
418
420
  skillDirectories,
419
421
  onPermissionRequest: approveAll,
@@ -84,4 +84,13 @@ test("createEphemeralAgentSession passes all MCP servers when the agent has no a
84
84
  });
85
85
  assert.deepEqual(context.createSessionOptions?.mcpServers, MCP_SERVERS);
86
86
  });
87
+ test("createEphemeralAgentSession excludes the synchronous built-in `task` tool", async (t) => {
88
+ const context = await loadIsolatedAgentsModule(t, "");
89
+ await createAgentSession(context.agentsModule, (options) => {
90
+ context.createSessionOptions = options;
91
+ });
92
+ const excluded = context.createSessionOptions?.excludedTools;
93
+ assert.ok(Array.isArray(excluded), "createSession should receive an excludedTools array");
94
+ assert.ok(excluded.includes("task"), "the built-in `task` tool spawns subagents in-turn and must be excluded so delegation stays async");
95
+ });
87
96
  //# sourceMappingURL=agents.mcp-servers.test.js.map
@@ -0,0 +1,17 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Built-in Copilot CLI tools that Chapterhouse disables on every agent-facing
3
+ // session.
4
+ //
5
+ // The CLI ships a built-in `task` tool that spawns subagents *synchronously
6
+ // inside the current turn* — `session.sendAndWait` does not resolve until the
7
+ // spawned subagent finishes. That blocks the chat: the orchestrator (or a
8
+ // persistent agent) cannot start the next queued turn while the subagent runs.
9
+ //
10
+ // Chapterhouse has its own non-blocking delegation path — the `delegate_to_agent`
11
+ // tool — which dispatches the work and returns immediately, notifying the user
12
+ // when the task completes. To keep delegation asynchronous, the synchronous
13
+ // `task` tool is excluded from every session the model talks through.
14
+ // ---------------------------------------------------------------------------
15
+ /** Built-in CLI tool names excluded from all Chapterhouse agent sessions. */
16
+ export const EXCLUDED_BUILTIN_TOOLS = ["task"];
17
+ //# sourceMappingURL=builtin-tools.js.map
@@ -10,6 +10,7 @@ import { CHAPTERHOUSE_VERSION } from "../version.js";
10
10
  import { config, DEFAULT_MODEL } from "../config.js";
11
11
  import { loadMcpConfig } from "./mcp-config.js";
12
12
  import { getSkillDirectories } from "./skills.js";
13
+ import { EXCLUDED_BUILTIN_TOOLS } from "./builtin-tools.js";
13
14
  import { resetClient } from "./client.js";
14
15
  import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, deleteCopilotSession, getTaskSessionKey, getDb, appendTaskEvent } from "../store/db.js";
15
16
  import { maybeWriteEpisode } from "./episode-writer.js";
@@ -385,6 +386,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
385
386
  systemMessage: { content: systemMessageContent },
386
387
  hooks: memoryHooks,
387
388
  tools,
389
+ excludedTools: EXCLUDED_BUILTIN_TOOLS,
388
390
  mcpServers,
389
391
  skillDirectories,
390
392
  onPermissionRequest: approveAll,
@@ -413,6 +415,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
413
415
  systemMessage: { content: systemMessageContent },
414
416
  hooks: memoryHooks,
415
417
  tools,
418
+ excludedTools: EXCLUDED_BUILTIN_TOOLS,
416
419
  mcpServers,
417
420
  skillDirectories,
418
421
  onPermissionRequest: approveAll,
@@ -670,6 +670,13 @@ test("initOrchestrator falls back to an available model and eagerly creates a se
670
670
  assert.equal(state.systemOptions?.memorySummary, "Chapterhouse: wiki summary");
671
671
  assert.equal(state.store.get("orchestrator_session_id"), "session-123");
672
672
  });
673
+ test("initOrchestrator excludes the synchronous built-in `task` tool from the orchestrator session", async (t) => {
674
+ const { orchestrator, state, client } = await loadOrchestratorModule(t);
675
+ await orchestrator.initOrchestrator(client);
676
+ const excluded = state.createSessionCalls[0]?.excludedTools;
677
+ assert.ok(Array.isArray(excluded), "createSession should receive an excludedTools array");
678
+ assert.ok(excluded.includes("task"), "the built-in `task` tool spawns subagents in-turn and must be excluded so the chat does not block");
679
+ });
673
680
  test("initOrchestrator passes hot-tier XML into the orchestrator system prompt when injection is enabled", async (t) => {
674
681
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
675
682
  hotTierXml: [
@@ -600,6 +600,7 @@ const MemoryScopeCountsSchema = z.object({
600
600
  decisions: z.number(),
601
601
  entities: z.number(),
602
602
  action_items: z.number(),
603
+ patterns: z.number(),
603
604
  });
604
605
  const MemoryScopeItemSchema = z.object({
605
606
  slug: z.string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
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"