chapterhouse 0.3.23 → 0.3.25

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,11 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { approveAll, defineTool } from "@github/copilot-sdk";
3
- import { getDb } from "../store/db.js";
3
+ import { getDb, appendTaskOutputDeltaEvent, appendTaskStatusEvent, updateTaskResult } from "../store/db.js";
4
4
  import { readdirSync, readFileSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
+ import { agentEventBus } from "./agent-event-bus.js";
9
10
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
10
11
  import { getRouterConfig, updateRouterConfig } from "./router.js";
11
12
  import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
@@ -137,6 +138,34 @@ export function createTools(deps) {
137
138
  // `executeOnSession` finishes.
138
139
  const parentActivity = getCurrentActivityCallback();
139
140
  const childUnsubs = [];
141
+ const emitTaskLogEvent = (taskEvent) => {
142
+ void agentEventBus.emit({
143
+ type: "session:tool_call",
144
+ sessionId: task.taskId,
145
+ payload: {
146
+ toolName: "",
147
+ toolArgs: {},
148
+ _kind: taskEvent.kind,
149
+ _seq: taskEvent.seq,
150
+ _ts: taskEvent.ts,
151
+ _summary: taskEvent.summary,
152
+ _text: taskEvent.text,
153
+ _status: taskEvent.status,
154
+ },
155
+ timestamp: new Date(taskEvent.ts),
156
+ });
157
+ };
158
+ let workerOutput = "";
159
+ childUnsubs.push(session.on("assistant.message_delta", (event) => {
160
+ const delta = typeof event.data.deltaContent === "string" ? event.data.deltaContent : "";
161
+ if (!delta)
162
+ return;
163
+ workerOutput += delta;
164
+ const taskEvent = appendTaskOutputDeltaEvent(task.taskId, delta);
165
+ if (!taskEvent)
166
+ return;
167
+ emitTaskLogEvent(taskEvent);
168
+ }));
140
169
  if (parentActivity) {
141
170
  childUnsubs.push(session.on("assistant.reasoning_delta", (event) => {
142
171
  parentActivity({
@@ -179,15 +208,21 @@ export function createTools(deps) {
179
208
  (async () => {
180
209
  try {
181
210
  const result = await session.sendAndWait({ prompt: taskPrompt }, timeoutMs);
182
- const output = result?.data?.content || "No response";
211
+ const output = workerOutput || result?.data?.content || "No response";
183
212
  completeTask(task.taskId, output);
184
- db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(output.slice(0, 10000), task.taskId);
213
+ updateTaskResult(task.taskId, "completed", output);
214
+ const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
215
+ if (statusEvent)
216
+ emitTaskLogEvent(statusEvent);
185
217
  deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
186
218
  }
187
219
  catch (err) {
188
220
  const msg = err instanceof Error ? err.message : String(err);
189
221
  failTask(task.taskId, msg);
190
- db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(msg, task.taskId);
222
+ updateTaskResult(task.taskId, "error", msg);
223
+ const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
224
+ if (statusEvent)
225
+ emitTaskLogEvent(statusEvent);
191
226
  deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
192
227
  }
193
228
  finally {
package/dist/store/db.js CHANGED
@@ -44,6 +44,14 @@ export function getDb() {
44
44
  started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
45
45
  completed_at DATETIME
46
46
  )
47
+ `);
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS projects (
50
+ slug TEXT PRIMARY KEY,
51
+ cwd TEXT NOT NULL,
52
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
53
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
54
+ )
47
55
  `);
48
56
  db.exec(`
49
57
  CREATE TABLE IF NOT EXISTS max_state (
@@ -118,19 +126,45 @@ export function getDb() {
118
126
  if (!taskCols.some((c) => c.name === "prompt")) {
119
127
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN prompt TEXT`);
120
128
  }
121
- // agent_task_events: append-only per-task tool-call activity log for /workers streaming
129
+ // agent_task_events: append-only per-task activity log for /workers streaming
122
130
  db.exec(`
123
131
  CREATE TABLE IF NOT EXISTS agent_task_events (
124
132
  id INTEGER PRIMARY KEY AUTOINCREMENT,
125
133
  task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
126
134
  seq INTEGER NOT NULL,
127
135
  ts INTEGER NOT NULL,
128
- kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete')),
136
+ kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
129
137
  tool_name TEXT,
130
- summary TEXT
138
+ summary TEXT,
139
+ text TEXT,
140
+ status TEXT
131
141
  )
132
142
  `);
133
143
  db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
144
+ // Migrate existing agent_task_events tables that lack text/status columns
145
+ const taskEventCols = db.prepare(`PRAGMA table_info(agent_task_events)`).all();
146
+ if (!taskEventCols.some((c) => c.name === "text") || !taskEventCols.some((c) => c.name === "status")) {
147
+ db.exec(`ALTER TABLE agent_task_events RENAME TO agent_task_events_old`);
148
+ db.exec(`
149
+ CREATE TABLE agent_task_events (
150
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
151
+ task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
152
+ seq INTEGER NOT NULL,
153
+ ts INTEGER NOT NULL,
154
+ kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
155
+ tool_name TEXT,
156
+ summary TEXT,
157
+ text TEXT,
158
+ status TEXT
159
+ )
160
+ `);
161
+ db.exec(`
162
+ INSERT INTO agent_task_events (id, task_id, seq, ts, kind, tool_name, summary)
163
+ SELECT id, task_id, seq, ts, kind, tool_name, summary FROM agent_task_events_old
164
+ `);
165
+ db.exec(`DROP TABLE agent_task_events_old`);
166
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
167
+ }
134
168
  // Migrate: add event_seq column to agent_tasks for monotonic event numbering
135
169
  const taskColsNow = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
136
170
  if (!taskColsNow.some((c) => c.name === 'event_seq')) {
@@ -334,7 +368,7 @@ export function getSessionMessages(sessionKey, limit) {
334
368
  * Uses a transaction so seq is monotonically incremented.
335
369
  * Non-fatal: silently ignores DB errors (task may not exist yet due to race).
336
370
  */
337
- export function appendTaskEvent(taskId, kind, toolName, summary) {
371
+ export function appendTaskEvent(taskId, kind, toolName, summary, text = null, status = null) {
338
372
  const db = getDb();
339
373
  try {
340
374
  return db.transaction(() => {
@@ -344,20 +378,30 @@ export function appendTaskEvent(taskId, kind, toolName, summary) {
344
378
  return undefined;
345
379
  const seq = row.event_seq;
346
380
  const ts = Date.now();
347
- const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary);
348
- return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary };
381
+ const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary, text, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary, text, status);
382
+ return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary, text, status };
349
383
  })();
350
384
  }
351
385
  catch {
352
386
  return undefined;
353
387
  }
354
388
  }
389
+ export function appendTaskOutputDeltaEvent(taskId, text) {
390
+ return appendTaskEvent(taskId, "output_delta", null, null, text, null);
391
+ }
392
+ export function appendTaskStatusEvent(taskId, status, summary = null) {
393
+ return appendTaskEvent(taskId, "task_status", null, summary, null, status);
394
+ }
395
+ export function updateTaskResult(taskId, status, result) {
396
+ const db = getDb();
397
+ db.prepare(`UPDATE agent_tasks SET status = ?, result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(status, result ? result.slice(0, 10000) : null, taskId);
398
+ }
355
399
  /**
356
400
  * Return all events for a task ordered by seq ascending.
357
401
  */
358
402
  export function getTaskEvents(taskId, afterSeq = 0) {
359
403
  const db = getDb();
360
- const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary
404
+ const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary, text, status
361
405
  FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
362
406
  return rows.map((r) => ({
363
407
  id: r.id,
@@ -367,6 +411,8 @@ export function getTaskEvents(taskId, afterSeq = 0) {
367
411
  kind: r.kind,
368
412
  toolName: r.tool_name,
369
413
  summary: r.summary,
414
+ text: r.text,
415
+ status: r.status,
370
416
  }));
371
417
  }
372
418
  export function closeDb() {
@@ -32,6 +32,7 @@ test("getDb initializes schema, state helpers, and conversation formatting", asy
32
32
  "worker_sessions",
33
33
  "agent_sessions",
34
34
  "agent_tasks",
35
+ "projects",
35
36
  "max_state",
36
37
  "conversation_log",
37
38
  "memories",
@@ -265,6 +266,62 @@ test("#86: agent_task_events table exists in schema after getDb()", async () =>
265
266
  dbModule.closeDb();
266
267
  }
267
268
  });
269
+ test("#158: appendTaskOutputDeltaEvent writes output_delta event with text", async () => {
270
+ const dbModule = await loadDbModule();
271
+ try {
272
+ const db = dbModule.getDb();
273
+ db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-delta-001", "coder", "delta test", "running");
274
+ const ev = dbModule.appendTaskOutputDeltaEvent("task-delta-001", "Hello world");
275
+ assert.ok(ev, "appendTaskOutputDeltaEvent must return the event");
276
+ assert.equal(ev.kind, "output_delta");
277
+ assert.equal(ev.text, "Hello world");
278
+ assert.equal(ev.toolName, null);
279
+ assert.equal(ev.status, null);
280
+ assert.equal(ev.seq, 1);
281
+ const events = dbModule.getTaskEvents("task-delta-001");
282
+ assert.equal(events.length, 1);
283
+ assert.equal(events[0].text, "Hello world");
284
+ assert.equal(events[0].kind, "output_delta");
285
+ }
286
+ finally {
287
+ dbModule.closeDb();
288
+ }
289
+ });
290
+ test("#158: appendTaskStatusEvent writes task_status event with status field", async () => {
291
+ const dbModule = await loadDbModule();
292
+ try {
293
+ const db = dbModule.getDb();
294
+ db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-status-001", "coder", "status test", "running");
295
+ const ev = dbModule.appendTaskStatusEvent("task-status-001", "completed", "All done");
296
+ assert.ok(ev, "appendTaskStatusEvent must return the event");
297
+ assert.equal(ev.kind, "task_status");
298
+ assert.equal(ev.status, "completed");
299
+ assert.equal(ev.summary, "All done");
300
+ assert.equal(ev.text, null);
301
+ assert.equal(ev.toolName, null);
302
+ const events = dbModule.getTaskEvents("task-status-001");
303
+ assert.equal(events.length, 1);
304
+ assert.equal(events[0].status, "completed");
305
+ assert.equal(events[0].kind, "task_status");
306
+ }
307
+ finally {
308
+ dbModule.closeDb();
309
+ }
310
+ });
311
+ test("#158: updateTaskResult updates agent_tasks status and result", async () => {
312
+ const dbModule = await loadDbModule();
313
+ try {
314
+ const db = dbModule.getDb();
315
+ db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-result-001", "coder", "result test", "running");
316
+ dbModule.updateTaskResult("task-result-001", "completed", "output text");
317
+ const row = db.prepare("SELECT status, result FROM agent_tasks WHERE task_id = ?").get("task-result-001");
318
+ assert.equal(row.status, "completed");
319
+ assert.equal(row.result, "output text");
320
+ }
321
+ finally {
322
+ dbModule.closeDb();
323
+ }
324
+ });
268
325
  // ---------------------------------------------------------------------------
269
326
  // normalizeSqliteTsToIso — unit tests
270
327
  // ---------------------------------------------------------------------------
@@ -1,47 +1,91 @@
1
1
  import { isAbsolute } from "node:path";
2
- import { readPage, writePage } from "./fs.js";
3
- const PROJECTS_INDEX_PATH = "pages/projects/index.md";
2
+ import { getDb } from "../store/db.js";
3
+ import { childLogger } from "../util/logger.js";
4
+ import { deletePage, pageExists, readPage } from "./fs.js";
5
+ const log = childLogger("project-registry");
6
+ const LEGACY_PROJECTS_INDEX_PATH = "pages/projects/index.md";
4
7
  const REGISTRY_HEADING = "## Project Registry";
5
8
  const OPENING_FENCE = "```yaml";
6
9
  const CLOSING_FENCE = "```";
7
10
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
8
11
  export function loadRegistry() {
9
- const content = readPage(PROJECTS_INDEX_PATH);
10
- if (!content)
11
- return {};
12
- const section = parseRegistrySection(content);
13
- if (!section)
14
- return {};
15
- return parseRegistryBlock(section.blockLines);
12
+ ensureRegistryMigrated();
13
+ const rows = getDb()
14
+ .prepare("SELECT slug, cwd FROM projects ORDER BY slug")
15
+ .all();
16
+ return Object.fromEntries(rows.map(({ slug, cwd }) => [slug, cwd]));
16
17
  }
17
18
  export function saveRegistry(registry) {
18
19
  validateRegistry(registry);
19
- const renderedSection = renderRegistrySection(registry);
20
- const current = readPage(PROJECTS_INDEX_PATH);
21
- if (!current) {
22
- writePage(PROJECTS_INDEX_PATH, `${renderedSection}\n`);
20
+ ensureRegistryMigrated();
21
+ const entries = Object.entries(registry).sort(([left], [right]) => left.localeCompare(right));
22
+ const db = getDb();
23
+ const save = db.transaction(() => {
24
+ db.prepare("DELETE FROM projects").run();
25
+ const insert = db.prepare(`
26
+ INSERT INTO projects (slug, cwd, created_at, updated_at)
27
+ VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
28
+ `);
29
+ for (const [slug, cwd] of entries) {
30
+ insert.run(slug, cwd);
31
+ }
32
+ });
33
+ save();
34
+ removeLegacyRegistryFile("Removed legacy wiki registry after SQLite save");
35
+ }
36
+ export function assertValidProjectSlug(slug) {
37
+ if (!SLUG_RE.test(slug)) {
38
+ throw new Error(`Project registry has invalid project slug '${slug}'. Expected a lowercase slug.`);
39
+ }
40
+ }
41
+ function ensureRegistryMigrated() {
42
+ const db = getDb();
43
+ let migratedRegistry;
44
+ const migrate = db.transaction(() => {
45
+ const row = db.prepare("SELECT COUNT(*) AS count FROM projects").get();
46
+ if (row.count > 0 || !pageExists(LEGACY_PROJECTS_INDEX_PATH)) {
47
+ return;
48
+ }
49
+ const legacyContent = readPage(LEGACY_PROJECTS_INDEX_PATH);
50
+ if (!legacyContent) {
51
+ return;
52
+ }
53
+ const registry = parseLegacyRegistry(legacyContent);
54
+ if (!registry) {
55
+ log.warn({ path: LEGACY_PROJECTS_INDEX_PATH }, "Legacy projects page had no registry section; skipping SQLite migration");
56
+ return;
57
+ }
58
+ const insert = db.prepare(`
59
+ INSERT INTO projects (slug, cwd, created_at, updated_at)
60
+ VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
61
+ `);
62
+ for (const [slug, cwd] of Object.entries(registry).sort(([left], [right]) => left.localeCompare(right))) {
63
+ insert.run(slug, cwd);
64
+ }
65
+ migratedRegistry = registry;
66
+ });
67
+ migrate.immediate();
68
+ if (!migratedRegistry) {
23
69
  return;
24
70
  }
25
- const section = parseRegistrySection(current);
26
- if (!section) {
27
- const trimmed = stripTrailingBlankLines(normalizeLineEndings(current));
28
- const prefix = trimmed ? `${trimmed}\n\n` : "";
29
- writePage(PROJECTS_INDEX_PATH, `${prefix}${renderedSection}\n`);
71
+ removeLegacyRegistryFile("Migrated project registry from wiki to SQLite", Object.keys(migratedRegistry).length);
72
+ }
73
+ function removeLegacyRegistryFile(message, count) {
74
+ if (!deletePage(LEGACY_PROJECTS_INDEX_PATH)) {
30
75
  return;
31
76
  }
32
- const before = stripTrailingBlankLines(section.before.join("\n"));
33
- const after = stripTrailingBlankLines(stripLeadingBlankLines(section.after).join("\n"));
34
- const pieces = [];
35
- if (before) {
36
- pieces.push(before);
37
- pieces.push("");
77
+ if (typeof count === "number") {
78
+ log.info({ count, path: LEGACY_PROJECTS_INDEX_PATH }, message);
79
+ return;
38
80
  }
39
- pieces.push(renderedSection);
40
- if (after) {
41
- pieces.push("");
42
- pieces.push(after);
81
+ log.info({ path: LEGACY_PROJECTS_INDEX_PATH }, message);
82
+ }
83
+ function parseLegacyRegistry(content) {
84
+ const section = parseRegistrySection(content);
85
+ if (!section) {
86
+ return undefined;
43
87
  }
44
- writePage(PROJECTS_INDEX_PATH, `${pieces.join("\n")}\n`);
88
+ return parseRegistryBlock(section.blockLines);
45
89
  }
46
90
  function parseRegistrySection(content) {
47
91
  const normalized = normalizeLineEndings(content);
@@ -81,11 +125,7 @@ function parseRegistrySection(content) {
81
125
  throw new Error("Project registry is malformed: unexpected content after the fenced block.");
82
126
  }
83
127
  }
84
- return {
85
- before: lines.slice(0, headingIndex),
86
- blockLines,
87
- after: lines.slice(sectionEnd),
88
- };
128
+ return { blockLines };
89
129
  }
90
130
  function parseRegistryBlock(lines) {
91
131
  const registry = {};
@@ -114,28 +154,11 @@ function validateRegistry(registry) {
114
154
  validatePath(path);
115
155
  }
116
156
  }
117
- export function assertValidProjectSlug(slug) {
118
- if (!SLUG_RE.test(slug)) {
119
- throw new Error(`Project registry has invalid project slug '${slug}'. Expected a lowercase slug.`);
120
- }
121
- }
122
157
  function validatePath(path) {
123
158
  if (!path || !isAbsolute(path)) {
124
159
  throw new Error(`Project registry path '${path}' must be an absolute path.`);
125
160
  }
126
161
  }
127
- function renderRegistrySection(registry) {
128
- const lines = [
129
- REGISTRY_HEADING,
130
- "",
131
- OPENING_FENCE,
132
- ...Object.keys(registry)
133
- .sort()
134
- .map((slug) => `${slug}: ${registry[slug]}`),
135
- CLOSING_FENCE,
136
- ];
137
- return lines.join("\n");
138
- }
139
162
  function findNextHeading(lines, startIndex) {
140
163
  for (let index = startIndex; index < lines.length; index += 1) {
141
164
  if (/^##\s+/.test(lines[index])) {
@@ -147,14 +170,4 @@ function findNextHeading(lines, startIndex) {
147
170
  function normalizeLineEndings(content) {
148
171
  return content.replace(/\r\n/g, "\n");
149
172
  }
150
- function stripTrailingBlankLines(content) {
151
- return content.replace(/\n+$/g, "");
152
- }
153
- function stripLeadingBlankLines(lines) {
154
- let start = 0;
155
- while (start < lines.length && lines[start].trim() === "") {
156
- start += 1;
157
- }
158
- return lines.slice(start);
159
- }
160
173
  //# sourceMappingURL=project-registry.js.map
@@ -6,67 +6,115 @@ const repoRoot = process.cwd();
6
6
  const sandboxRoot = join(repoRoot, ".test-work", `wiki-project-registry-${process.pid}`);
7
7
  process.env.CHAPTERHOUSE_HOME = sandboxRoot;
8
8
  async function loadModules() {
9
- const nonce = `${Date.now()}-${Math.random()}`;
10
- const projectRegistry = await import(new URL(`./project-registry.js?case=${nonce}`, import.meta.url).href);
11
- const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
12
- return { projectRegistry, wikiFs };
9
+ const projectRegistry = await import(new URL("./project-registry.js", import.meta.url).href);
10
+ const wikiFs = await import(new URL("./fs.js", import.meta.url).href);
11
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
12
+ return { projectRegistry, wikiFs, dbModule };
13
13
  }
14
14
  function resetSandbox() {
15
15
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
16
16
  rmSync(sandboxRoot, { recursive: true, force: true });
17
17
  }
18
- test.beforeEach(() => {
18
+ test.beforeEach(async () => {
19
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
20
+ dbModule.closeDb();
19
21
  resetSandbox();
20
22
  });
21
- test.after(() => {
23
+ test.after(async () => {
24
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
25
+ dbModule.closeDb();
22
26
  rmSync(sandboxRoot, { recursive: true, force: true });
23
27
  });
24
- test("loadRegistry returns an empty object when pages/projects/index.md is absent", async () => {
25
- const { projectRegistry } = await loadModules();
26
- assert.deepEqual(projectRegistry.loadRegistry(), {});
28
+ test("loadRegistry returns an empty object when the projects table is empty", async () => {
29
+ const { projectRegistry, dbModule } = await loadModules();
30
+ try {
31
+ assert.deepEqual(projectRegistry.loadRegistry(), {});
32
+ }
33
+ finally {
34
+ dbModule.closeDb();
35
+ }
27
36
  });
28
- test("loadRegistry returns an empty object when the projects page has no registry section", async () => {
29
- const { projectRegistry, wikiFs } = await loadModules();
30
- wikiFs.writePage("pages/projects/index.md", "---\ntitle: Projects\nsummary: Project pages live here.\nupdated: 2026-05-12\n---\n\n# Projects\n\nTracked project pages.\n");
31
- assert.deepEqual(projectRegistry.loadRegistry(), {});
37
+ test("saveRegistry persists the registry in SQLite", async () => {
38
+ const { projectRegistry, dbModule, wikiFs } = await loadModules();
39
+ try {
40
+ projectRegistry.saveRegistry({
41
+ "docs-site": "/home/bjk/projects/docs-site",
42
+ chapterhouse: "/home/bjk/projects/chapterhouse",
43
+ });
44
+ assert.deepEqual(projectRegistry.loadRegistry(), {
45
+ chapterhouse: "/home/bjk/projects/chapterhouse",
46
+ "docs-site": "/home/bjk/projects/docs-site",
47
+ });
48
+ assert.equal(wikiFs.readPage("pages/projects/index.md"), undefined);
49
+ assert.deepEqual(dbModule.getDb().prepare("SELECT slug, cwd FROM projects ORDER BY slug").all(), [
50
+ { slug: "chapterhouse", cwd: "/home/bjk/projects/chapterhouse" },
51
+ { slug: "docs-site", cwd: "/home/bjk/projects/docs-site" },
52
+ ]);
53
+ }
54
+ finally {
55
+ dbModule.closeDb();
56
+ }
32
57
  });
33
- test("loadRegistry parses the fenced yaml registry block", async () => {
34
- const { projectRegistry, wikiFs } = await loadModules();
35
- wikiFs.writePage("pages/projects/index.md", "---\ntitle: Projects\nsummary: Canonical project registry.\nupdated: 2026-05-12\n---\n\n# Projects\n\n## Project Registry\n\n```yaml\nchapterhouse: /home/bjk/projects/chapterhouse\ndocs-site: /home/bjk/Documents/docs site\n```\n");
36
- assert.deepEqual(projectRegistry.loadRegistry(), {
37
- chapterhouse: "/home/bjk/projects/chapterhouse",
38
- "docs-site": "/home/bjk/Documents/docs site",
39
- });
58
+ test("saveRegistry replaces prior SQLite-backed registry contents", async () => {
59
+ const { projectRegistry, dbModule } = await loadModules();
60
+ try {
61
+ projectRegistry.saveRegistry({
62
+ alpha: "/srv/alpha",
63
+ zeta: "/srv/zeta",
64
+ });
65
+ projectRegistry.saveRegistry({
66
+ beta: "/srv/beta",
67
+ alpha: "/srv/alpha",
68
+ });
69
+ assert.deepEqual(projectRegistry.loadRegistry(), {
70
+ alpha: "/srv/alpha",
71
+ beta: "/srv/beta",
72
+ });
73
+ assert.deepEqual(dbModule.getDb().prepare("SELECT slug, cwd FROM projects ORDER BY slug").all(), [
74
+ { slug: "alpha", cwd: "/srv/alpha" },
75
+ { slug: "beta", cwd: "/srv/beta" },
76
+ ]);
77
+ }
78
+ finally {
79
+ dbModule.closeDb();
80
+ }
40
81
  });
41
- test("loadRegistry rejects malformed registry content", async () => {
42
- const { projectRegistry, wikiFs } = await loadModules();
43
- wikiFs.writePage("pages/projects/index.md", "# Projects\n\n## Project Registry\n\n```yaml\nChapterHouse: /home/bjk/projects/chapterhouse\nrelative: ./docs-site\nbroken line\nchapterhouse: /home/bjk/projects/chapterhouse\n```\n");
44
- assert.throws(() => projectRegistry.loadRegistry(), /invalid project slug|absolute path|malformed registry line|duplicate project slug/);
45
- });
46
- test("saveRegistry creates a new registry page with deterministic ordering", async () => {
47
- const { projectRegistry, wikiFs } = await loadModules();
48
- projectRegistry.saveRegistry({
49
- "docs-site": "/home/bjk/projects/docs-site",
50
- chapterhouse: "/home/bjk/projects/chapterhouse",
51
- });
52
- assert.equal(wikiFs.readPage("pages/projects/index.md"), "## Project Registry\n\n```yaml\nchapterhouse: /home/bjk/projects/chapterhouse\ndocs-site: /home/bjk/projects/docs-site\n```\n");
53
- });
54
- test("saveRegistry rewrites only the registry section and preserves surrounding content", async () => {
55
- const { projectRegistry, wikiFs } = await loadModules();
56
- wikiFs.writePage("pages/projects/index.md", "---\ntitle: Projects\nsummary: Canonical project registry.\nupdated: 2026-05-12\n---\n\n# Projects\n\nIntro paragraph.\n\n## Project Registry\n\n```yaml\nzeta: /srv/zeta\nalpha: /srv/alpha\n```\n\n## Notes\n\nKeep this section untouched.\n");
57
- projectRegistry.saveRegistry({
58
- beta: "/srv/beta",
59
- alpha: "/srv/alpha",
60
- });
61
- assert.equal(wikiFs.readPage("pages/projects/index.md"), "---\ntitle: Projects\nsummary: Canonical project registry.\nupdated: 2026-05-12\n---\n\n# Projects\n\nIntro paragraph.\n\n## Project Registry\n\n```yaml\nalpha: /srv/alpha\nbeta: /srv/beta\n```\n\n## Notes\n\nKeep this section untouched.\n");
62
- assert.deepEqual(projectRegistry.loadRegistry(), {
63
- alpha: "/srv/alpha",
64
- beta: "/srv/beta",
65
- });
82
+ test("loadRegistry migrates the legacy wiki registry into SQLite and removes the file", async () => {
83
+ const { projectRegistry, wikiFs, dbModule } = await loadModules();
84
+ try {
85
+ wikiFs.writePage("pages/projects/index.md", "---\n"
86
+ + "title: Projects\n"
87
+ + "summary: Canonical project registry.\n"
88
+ + "updated: 2026-05-12\n"
89
+ + "---\n\n"
90
+ + "# Projects\n\n"
91
+ + "## Project Registry\n\n"
92
+ + "```yaml\n"
93
+ + "chapterhouse: /home/bjk/projects/chapterhouse\n"
94
+ + "docs-site: /home/bjk/Documents/docs site\n"
95
+ + "```\n");
96
+ assert.deepEqual(projectRegistry.loadRegistry(), {
97
+ chapterhouse: "/home/bjk/projects/chapterhouse",
98
+ "docs-site": "/home/bjk/Documents/docs site",
99
+ });
100
+ assert.equal(wikiFs.pageExists("pages/projects/index.md"), false);
101
+ assert.deepEqual(dbModule.getDb().prepare("SELECT slug, cwd FROM projects ORDER BY slug").all(), [
102
+ { slug: "chapterhouse", cwd: "/home/bjk/projects/chapterhouse" },
103
+ { slug: "docs-site", cwd: "/home/bjk/Documents/docs site" },
104
+ ]);
105
+ }
106
+ finally {
107
+ dbModule.closeDb();
108
+ }
66
109
  });
67
110
  test("saveRegistry rejects invalid slugs and non-absolute paths", async () => {
68
- const { projectRegistry } = await loadModules();
69
- assert.throws(() => projectRegistry.saveRegistry({ ChapterHouse: "/home/bjk/projects/chapterhouse" }), /invalid project slug/);
70
- assert.throws(() => projectRegistry.saveRegistry({ chapterhouse: "./relative" }), /absolute path/);
111
+ const { projectRegistry, dbModule } = await loadModules();
112
+ try {
113
+ assert.throws(() => projectRegistry.saveRegistry({ ChapterHouse: "/home/bjk/projects/chapterhouse" }), /invalid project slug/);
114
+ assert.throws(() => projectRegistry.saveRegistry({ chapterhouse: "./relative" }), /absolute path/);
115
+ }
116
+ finally {
117
+ dbModule.closeDb();
118
+ }
71
119
  });
72
120
  //# sourceMappingURL=project-registry.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
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"