chapterhouse 0.3.22 → 0.3.23

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.
@@ -361,48 +361,90 @@ app.get("/api/workers/:taskId", (req, res) => {
361
361
  completedAt: row.completed_at,
362
362
  });
363
363
  });
364
- // Historical event log for a task (catch-up on page load / SSE reconnect).
365
- // Ring buffer (fast, in-memory) is checked first; falls back to SQLite for
366
- // completed tasks whose buffer has been cleared.
364
+ const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
365
+ // SSE stream for per-task tool-call activity.
366
+ // Replays buffered/persisted backlog on connect, then streams live events until
367
+ // the task reaches a terminal state.
367
368
  app.get("/api/workers/:taskId/events", (req, res) => {
368
369
  const taskId = req.params.taskId;
369
- const afterSeqRaw = req.query.afterSeq;
370
- const afterSeq = typeof afterSeqRaw === "string" && !isNaN(Number(afterSeqRaw)) ? Number(afterSeqRaw) : 0;
371
370
  const taskRow = getDb()
372
371
  .prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
373
372
  .get(taskId);
374
373
  if (!taskRow) {
375
374
  throw new NotFoundError("Task not found");
376
375
  }
377
- const ringEvents = getTaskLogEvents(taskId, afterSeq);
378
- const events = ringEvents.length > 0 ? ringEvents : getTaskEvents(taskId, afterSeq);
379
- res.json({ taskId, events });
380
- });
381
- // SSE stream for per-task live tool-call activity.
382
- // Uses the ring-buffer subscriber (subscribeTaskLog) so events fire immediately
383
- // from in-memory state rather than needing an extra SQLite read.
384
- app.get("/api/workers/:taskId/events/stream", (req, res) => {
385
- const taskId = req.params.taskId;
386
- const taskRow = getDb()
387
- .prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
388
- .get(taskId);
389
- if (!taskRow) {
390
- throw new NotFoundError("Task not found");
376
+ res.setHeader("Content-Type", "text/event-stream");
377
+ res.setHeader("Cache-Control", "no-cache");
378
+ res.setHeader("Connection", "keep-alive");
379
+ res.setHeader("X-Accel-Buffering", "no");
380
+ res.flushHeaders();
381
+ const rawLastId = req.headers["last-event-id"];
382
+ const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
383
+ ? parseInt(rawLastId.trim(), 10)
384
+ : undefined;
385
+ const sendEvent = (event) => {
386
+ const payload = {
387
+ taskId: event.taskId,
388
+ seq: event.seq,
389
+ ts: event.ts,
390
+ kind: event.kind,
391
+ toolName: event.toolName,
392
+ summary: event.summary,
393
+ };
394
+ res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
395
+ };
396
+ let replayHighSeq = lastSeq;
397
+ if (lastSeq !== undefined) {
398
+ const bufferedEvents = getTaskLogEvents(taskId);
399
+ const oldestBufferedSeq = bufferedEvents[0]?.seq;
400
+ const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
401
+ if (bufferMissesRange) {
402
+ const dbEvents = getTaskEvents(taskId, lastSeq);
403
+ for (const event of dbEvents) {
404
+ sendEvent(event);
405
+ if (replayHighSeq === undefined || event.seq > replayHighSeq) {
406
+ replayHighSeq = event.seq;
407
+ }
408
+ }
409
+ }
391
410
  }
392
- res.writeHead(200, {
393
- "Content-Type": "text/event-stream",
394
- "Cache-Control": "no-cache",
395
- Connection: "keep-alive",
411
+ const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
412
+ const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
413
+ for (const event of backlog) {
414
+ sendEvent(event);
415
+ }
416
+ const isTerminal = () => {
417
+ const row = getDb()
418
+ .prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
419
+ .get(taskId);
420
+ return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
421
+ };
422
+ res.write(`: connected task=${taskId}\n\n`);
423
+ if (isTerminal()) {
424
+ res.end();
425
+ return;
426
+ }
427
+ const heartbeat = setInterval(() => {
428
+ res.write(`: keep-alive\n\n`);
429
+ }, 15_000);
430
+ const unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
431
+ sendEvent(event);
396
432
  });
397
- res.write(formatSseData({ type: "connected", taskId }));
398
- const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
399
- // subscribeTaskLog delivers ring-buffer-sourced events as they arrive.
400
- const unsub = subscribeTaskLog(taskId, (event) => {
401
- res.write(formatSseData({ type: "task_event", ...event }));
433
+ const unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
434
+ if (event.sessionId === taskId && isTerminal()) {
435
+ res.end();
436
+ }
437
+ });
438
+ const unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
439
+ if (event.sessionId === taskId && isTerminal()) {
440
+ res.end();
441
+ }
402
442
  });
403
443
  req.on("close", () => {
404
444
  clearInterval(heartbeat);
405
- unsub();
445
+ unsubscribeTaskLog();
446
+ unsubscribeDestroyed();
447
+ unsubscribeError();
406
448
  });
407
449
  });
408
450
  // ---------------------------------------------------------------------------
@@ -0,0 +1,336 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ const repoRoot = process.cwd();
8
+ const testWorkRoot = join(repoRoot, ".test-work", `worker-events-sse-${process.pid}`);
9
+ let instanceCounter = 0;
10
+ const CONTROL_PREFIX = "__CH__";
11
+ const READY_PREFIX = "__CH_READY__";
12
+ const controlledServerScript = `
13
+ import readline from "node:readline";
14
+ import { startApiServer } from "./dist/api/server.js";
15
+ import { agentEventBus } from "./dist/copilot/agent-event-bus.js";
16
+ import { initTaskEventLog } from "./dist/copilot/task-event-log.js";
17
+ import { appendTaskEvent, getDb } from "./dist/store/db.js";
18
+
19
+ const db = getDb();
20
+ initTaskEventLog();
21
+ const reply = (payload) => process.stdout.write("${CONTROL_PREFIX}" + JSON.stringify(payload) + "\\n");
22
+
23
+ function emitTaskEvent(taskId, kind, toolName, summary) {
24
+ const event = appendTaskEvent(taskId, kind, toolName ?? null, summary ?? null);
25
+ if (!event) {
26
+ throw new Error("appendTaskEvent returned undefined");
27
+ }
28
+ agentEventBus.emit({
29
+ type: "session:tool_call",
30
+ sessionId: taskId,
31
+ payload: {
32
+ _kind: event.kind,
33
+ _seq: event.seq,
34
+ _ts: event.ts,
35
+ _summary: event.summary,
36
+ ...(event.toolName ? { toolName: event.toolName } : {}),
37
+ },
38
+ });
39
+ return event;
40
+ }
41
+
42
+ await startApiServer();
43
+ process.stdout.write("${READY_PREFIX}\\n");
44
+
45
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
46
+ for await (const line of rl) {
47
+ if (!line.trim()) continue;
48
+
49
+ const command = JSON.parse(line);
50
+ try {
51
+ switch (command.type) {
52
+ case "createTask":
53
+ db.prepare(
54
+ "INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)"
55
+ ).run(command.taskId, command.agentSlug ?? "coder", command.description ?? "Test worker task", command.status ?? "running");
56
+ reply({ ok: true });
57
+ break;
58
+ case "appendEvent":
59
+ reply({
60
+ ok: true,
61
+ event: emitTaskEvent(command.taskId, command.kind, command.toolName ?? null, command.summary ?? null),
62
+ });
63
+ break;
64
+ case "finishTask": {
65
+ db.prepare(
66
+ "UPDATE agent_tasks SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?"
67
+ ).run(command.status, command.taskId);
68
+ agentEventBus.emit({
69
+ type: command.status === "failed" ? "session:error" : "session:destroyed",
70
+ sessionId: command.taskId,
71
+ payload: command.status === "failed"
72
+ ? { agentName: command.agentName ?? "coder", error: command.reason ?? "Task failed" }
73
+ : { agentName: command.agentName ?? "coder", reason: command.reason ?? "complete" },
74
+ });
75
+ reply({ ok: true });
76
+ break;
77
+ }
78
+ default:
79
+ throw new Error(\`Unknown command: \${command.type}\`);
80
+ }
81
+ } catch (error) {
82
+ reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
83
+ }
84
+ }
85
+ `;
86
+ async function getFreePort() {
87
+ const server = createServer();
88
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
89
+ const address = server.address();
90
+ assert.ok(address && typeof address === "object");
91
+ await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
92
+ return address.port;
93
+ }
94
+ async function stopChild(child) {
95
+ if (child.exitCode !== null)
96
+ return;
97
+ child.kill("SIGTERM");
98
+ await new Promise((resolve) => {
99
+ child.once("exit", () => resolve());
100
+ setTimeout(() => {
101
+ if (child.exitCode === null)
102
+ child.kill("SIGKILL");
103
+ }, 2_000);
104
+ });
105
+ }
106
+ async function withControlledServer(run, timeoutMs = 30_000) {
107
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
108
+ const instanceRoot = `${testWorkRoot}-${Date.now()}-${++instanceCounter}`;
109
+ rmSync(instanceRoot, { recursive: true, force: true });
110
+ mkdirSync(instanceRoot, { recursive: true });
111
+ const port = await getFreePort();
112
+ const logs = [];
113
+ let stdoutBuffer = "";
114
+ let readyResolve;
115
+ let readyReject;
116
+ const ready = new Promise((resolve, reject) => {
117
+ readyResolve = resolve;
118
+ readyReject = reject;
119
+ });
120
+ const pendingReplies = [];
121
+ const child = spawn(process.execPath, ["--input-type=module", "-e", controlledServerScript], {
122
+ cwd: repoRoot,
123
+ env: {
124
+ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("COPILOT_") && !key.startsWith("NODE_TEST_"))),
125
+ CHAPTERHOUSE_DISABLE_DOTENV: "1",
126
+ CHAPTERHOUSE_HOME: instanceRoot,
127
+ API_HOST: "127.0.0.1",
128
+ API_PORT: String(port),
129
+ API_TOKEN: "test-token",
130
+ },
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ });
133
+ child.stdout?.on("data", (chunk) => {
134
+ stdoutBuffer += String(chunk);
135
+ const lines = stdoutBuffer.split("\n");
136
+ stdoutBuffer = lines.pop() ?? "";
137
+ for (const line of lines) {
138
+ if (line === READY_PREFIX) {
139
+ readyResolve?.();
140
+ }
141
+ else if (line.startsWith(CONTROL_PREFIX)) {
142
+ const pending = pendingReplies.shift();
143
+ if (!pending)
144
+ continue;
145
+ clearTimeout(pending.timer);
146
+ pending.resolve(JSON.parse(line.slice(CONTROL_PREFIX.length)));
147
+ }
148
+ else if (line.length > 0) {
149
+ logs.push(`${line}\n`);
150
+ }
151
+ }
152
+ });
153
+ child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
154
+ const sendCommand = async (command) => {
155
+ assert.ok(child.stdin, "Controlled server stdin should be writable");
156
+ const reply = new Promise((resolve, reject) => {
157
+ const timer = setTimeout(() => {
158
+ reject(new Error(`Timed out waiting for control reply for ${command.type}`));
159
+ }, 2_000);
160
+ pendingReplies.push({ resolve, reject, timer });
161
+ });
162
+ child.stdin.write(`${JSON.stringify(command)}\n`);
163
+ const payload = await reply;
164
+ if (!payload.ok) {
165
+ throw new Error(payload.error ?? `Control command ${command.type} failed`);
166
+ }
167
+ return payload;
168
+ };
169
+ const baseUrl = `http://127.0.0.1:${port}`;
170
+ try {
171
+ const timeout = setTimeout(() => {
172
+ readyReject?.(new Error(`Server did not start in ${timeoutMs}ms:\n${logs.join("")}`));
173
+ }, timeoutMs);
174
+ try {
175
+ await ready;
176
+ }
177
+ finally {
178
+ clearTimeout(timeout);
179
+ }
180
+ if (child.exitCode !== null) {
181
+ throw new Error(`Server exited early:\n${logs.join("")}`);
182
+ }
183
+ await run({ baseUrl, authHeader: "Bearer test-token", sendCommand });
184
+ }
185
+ finally {
186
+ child.stdin?.end();
187
+ await stopChild(child);
188
+ rmSync(instanceRoot, { recursive: true, force: true });
189
+ }
190
+ }
191
+ function createSseReader(body) {
192
+ const reader = body.getReader();
193
+ const decoder = new TextDecoder();
194
+ let leftover = "";
195
+ function drainFrames() {
196
+ const frames = [];
197
+ const parts = leftover.split("\n\n");
198
+ leftover = parts.pop() ?? "";
199
+ for (const part of parts) {
200
+ if (!part.trim())
201
+ continue;
202
+ const frame = { data: null };
203
+ for (const line of part.split("\n")) {
204
+ if (line.startsWith(":"))
205
+ continue;
206
+ if (line.startsWith("id: "))
207
+ frame.id = line.slice(4);
208
+ else if (line.startsWith("event: "))
209
+ frame.event = line.slice(7);
210
+ else if (line.startsWith("data: ")) {
211
+ try {
212
+ frame.data = JSON.parse(line.slice(6));
213
+ }
214
+ catch {
215
+ frame.data = line.slice(6);
216
+ }
217
+ }
218
+ }
219
+ if (frame.data !== null)
220
+ frames.push(frame);
221
+ }
222
+ return frames;
223
+ }
224
+ async function readFrames(count, timeoutMs = 3_000) {
225
+ const frames = [];
226
+ const deadline = Date.now() + timeoutMs;
227
+ while (frames.length < count && Date.now() < deadline) {
228
+ const { value, done } = await Promise.race([
229
+ reader.read(),
230
+ new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)),
231
+ ]);
232
+ if (done)
233
+ break;
234
+ if (value !== undefined) {
235
+ leftover += decoder.decode(value, { stream: true });
236
+ frames.push(...drainFrames());
237
+ }
238
+ }
239
+ return frames;
240
+ }
241
+ async function waitForClose(timeoutMs = 3_000) {
242
+ const deadline = Date.now() + timeoutMs;
243
+ while (Date.now() < deadline) {
244
+ const { value, done } = await Promise.race([
245
+ reader.read(),
246
+ new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)),
247
+ ]);
248
+ if (done)
249
+ return true;
250
+ if (value !== undefined) {
251
+ leftover += decoder.decode(value, { stream: true });
252
+ void drainFrames();
253
+ }
254
+ }
255
+ return false;
256
+ }
257
+ async function cancel() {
258
+ try {
259
+ await reader.cancel();
260
+ }
261
+ catch {
262
+ // Ignore already-closed streams.
263
+ }
264
+ }
265
+ return { readFrames, waitForClose, cancel };
266
+ }
267
+ test("worker events SSE replays backlog, streams live events, and closes on completion", async () => {
268
+ await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
269
+ const taskId = "worker-events-sse-001";
270
+ await sendCommand({
271
+ type: "createTask",
272
+ taskId,
273
+ description: "Stream worker task events",
274
+ status: "running",
275
+ });
276
+ await sendCommand({
277
+ type: "appendEvent",
278
+ taskId,
279
+ kind: "tool_start",
280
+ toolName: "bash",
281
+ summary: "npm run build",
282
+ });
283
+ await sendCommand({
284
+ type: "appendEvent",
285
+ taskId,
286
+ kind: "tool_complete",
287
+ summary: "build complete",
288
+ });
289
+ const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
290
+ headers: {
291
+ Authorization: authHeader,
292
+ Accept: "text/event-stream",
293
+ },
294
+ });
295
+ assert.equal(response.status, 200);
296
+ assert.ok(response.headers.get("content-type")?.includes("text/event-stream"), `Content-Type should include text/event-stream, got ${response.headers.get("content-type")}`);
297
+ assert.ok(response.body, "SSE response should have a readable body");
298
+ const reader = createSseReader(response.body);
299
+ try {
300
+ const backlogFrames = await reader.readFrames(2, 2_000);
301
+ assert.equal(backlogFrames.length, 2, "Expected backlog replay frames");
302
+ assert.deepEqual(backlogFrames.map((frame) => frame.id), ["1", "2"], "Backlog frames should preserve task event sequence ids");
303
+ const backlogKinds = backlogFrames.map((frame) => frame.data.kind);
304
+ assert.deepEqual(backlogKinds, ["tool_start", "tool_complete"]);
305
+ await sendCommand({
306
+ type: "appendEvent",
307
+ taskId,
308
+ kind: "tool_start",
309
+ toolName: "view",
310
+ summary: "README.md",
311
+ });
312
+ const liveFrames = await reader.readFrames(1, 2_000);
313
+ assert.equal(liveFrames.length, 1, "Expected one live frame");
314
+ assert.equal(liveFrames[0]?.id, "3");
315
+ assert.deepEqual(liveFrames[0]?.data, {
316
+ taskId,
317
+ seq: 3,
318
+ ts: (liveFrames[0]?.data).ts,
319
+ kind: "tool_start",
320
+ toolName: "view",
321
+ summary: "README.md",
322
+ });
323
+ await sendCommand({
324
+ type: "finishTask",
325
+ taskId,
326
+ status: "completed",
327
+ reason: "complete",
328
+ });
329
+ assert.equal(await reader.waitForClose(2_000), true, "SSE stream should close when the task completes");
330
+ }
331
+ finally {
332
+ await reader.cancel();
333
+ }
334
+ }, 30_000);
335
+ });
336
+ //# sourceMappingURL=worker-events-sse.integration.test.js.map
@@ -574,10 +574,16 @@ async function executeOnSession(manager, item) {
574
574
  });
575
575
  const unsubSubDoneDb = session.on("subagent.completed", (event) => {
576
576
  try {
577
- spawnArgsMap.delete(event.data.toolCallId);
578
- activeSubagentTaskIds.delete(event.data.toolCallId);
579
- db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
580
- const taskId = event.data.toolCallId;
577
+ const doneData = event.data;
578
+ const taskId = String(doneData.toolCallId ?? "");
579
+ const finalResult = typeof doneData.result?.detailedContent === "string"
580
+ ? doneData.result.detailedContent
581
+ : typeof doneData.result?.content === "string"
582
+ ? doneData.result.content
583
+ : null;
584
+ spawnArgsMap.delete(taskId);
585
+ activeSubagentTaskIds.delete(taskId);
586
+ db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
581
587
  const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
582
588
  void agentEventBus.emit({
583
589
  type: "session:destroyed",
@@ -587,7 +593,6 @@ async function executeOnSession(manager, item) {
587
593
  timestamp: new Date(),
588
594
  });
589
595
  // Emit turn:delta with subagent completed part (coexistence — #130)
590
- const doneData = event.data;
591
596
  const donePart = {
592
597
  type: "subagent",
593
598
  toolCallId: String(doneData.toolCallId ?? ""),
@@ -914,7 +914,7 @@ test("S5-01: subagent.started event inserts an adhoc row into agent_tasks", asyn
914
914
  // Resolve the pending sendAndWait so the test can clean up
915
915
  state.pendingReject?.(new Error("test teardown"));
916
916
  });
917
- test("S5-01: subagent.completed event updates agent_tasks status to completed", async (t) => {
917
+ test("S5-01: subagent.completed event persists final result text to agent_tasks", async (t) => {
918
918
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
919
919
  sendResult: "__PENDING__",
920
920
  });
@@ -934,10 +934,15 @@ test("S5-01: subagent.completed event updates agent_tasks status to completed",
934
934
  agentName: "Wash",
935
935
  agentDisplayName: "Wash — Frontend Dev",
936
936
  durationMs: 1234,
937
+ result: {
938
+ detailedContent: "Workers tab refreshes with persisted final output",
939
+ },
937
940
  });
938
941
  const updateWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("completed"));
939
942
  assert.ok(updateWrite, "subagent.completed must UPDATE agent_tasks to completed");
943
+ assert.ok(updateWrite.sql.includes("result = ?"), "completed subagent updates must persist the final result text");
940
944
  assert.ok(JSON.stringify(updateWrite.args).includes("subagent-call-002"), "UPDATE must target the correct task_id");
945
+ assert.ok(JSON.stringify(updateWrite.args).includes("Workers tab refreshes with persisted final output"), "UPDATE must store the final result text in agent_tasks.result");
941
946
  state.pendingReject?.(new Error("test teardown"));
942
947
  });
943
948
  test("S5-01: subagent.failed event updates agent_tasks status to error", async (t) => {
@@ -7,11 +7,12 @@
7
7
  * in sync without the caller needing to wire anything extra.
8
8
  *
9
9
  * Consumers:
10
- * 1. `GET /api/workers/:taskId/events` — REST hydration on page-load / SSE reconnect.
11
- * The ring buffer is checked first (fast path, in-memory); SQLite is the fallback
12
- * for completed tasks whose ring buffer has been cleared.
13
- * 2. Per-task SSE subscribers — `subscribeTaskLog` delivers events as they arrive
14
- * so the SSE frame fires immediately (no SQLite round-trip).
10
+ * 1. `GET /api/workers/:taskId/events` — SSE backlog replay on connect and
11
+ * reconnect. The ring buffer is checked first (fast path, in-memory);
12
+ * SQLite is the fallback for completed tasks whose ring buffer has been
13
+ * cleared.
14
+ * 2. Per-task SSE subscribers `subscribeTaskLog` delivers live events as
15
+ * they arrive so the SSE frame fires immediately (no SQLite round-trip).
15
16
  *
16
17
  * Lifecycle:
17
18
  * - `initTaskEventLog()` must be called once from `initOrchestrator()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
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"