chapterhouse 0.3.22 → 0.3.24

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.
@@ -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/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 (
@@ -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",
@@ -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.22",
3
+ "version": "0.3.24",
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"