chapterhouse 0.9.2 → 0.10.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.
Files changed (111) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  107. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  109. package/web/dist/index.html +1 -1
  110. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  111. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -83,6 +83,24 @@ test("runConsolidation merges fragment pages into a canonical page and repoints
83
83
  assert.equal(fragmentRow, undefined);
84
84
  assert.ok(repointed.c >= 1, "links should be repointed to the canonical page");
85
85
  });
86
+ test("runConsolidation rewrites only wiki links when merging fragments", async () => {
87
+ const { dbModule, wikiFs, indexManager, consolidation } = await loadModules();
88
+ const db = dbModule.getDb();
89
+ wikiFs.ensureWikiStructure();
90
+ wikiFs.writePage("pages/topics/rust/index.md", makePage("Rust", "Main Rust page", "2026-05-10", "Canonical summary.", `### 2026-05-10T10:00:00.000Z\n\nCanonical fact.`));
91
+ wikiFs.writePage("pages/topics/ruts/index.md", makePage("Ruts", "Fragment Rust page", "2026-05-11", "Fragment summary.", `### 2026-05-11T10:00:00.000Z\n\nFragment fact.`));
92
+ wikiFs.writePage("pages/topics/terrain/index.md", makePage("Terrain", "Terrain topic", "2026-05-10", "Ruts should remain plain prose while [[Ruts]] and [[Ruts|rut-like paths]] are repointed.", `### 2026-05-10T08:00:00.000Z\n\nRuts are hazardous, but [[Ruts|the fragment link]] should move.`));
93
+ indexManager.rebuildWikiIndex();
94
+ await consolidation.runConsolidationWithDeps(db, {
95
+ now: () => new Date("2026-05-14T22:30:03.086Z"),
96
+ synthesizeTruth: async () => "unused",
97
+ commitWikiChanges: async () => false,
98
+ });
99
+ const terrain = wikiFs.readPage("pages/topics/terrain/index.md") ?? "";
100
+ assert.match(terrain, /Ruts are hazardous, but \[\[Rust\|the fragment link\]\] should move\./);
101
+ assert.match(terrain, /Ruts are hazardous/);
102
+ assert.doesNotMatch(terrain, /\[\[Ruts(?:\||\]\])/);
103
+ });
86
104
  test("runConsolidation removes orphaned wiki_links rows", async () => {
87
105
  const { dbModule, wikiFs, indexManager, consolidation } = await loadModules();
88
106
  const db = dbModule.getDb();
@@ -140,4 +158,31 @@ test("runConsolidation caps truth rewrites before exceeding the LLM budget", asy
140
158
  assert.equal(result.llmCallsUsed, 18);
141
159
  assert.equal(llmCalls, 18);
142
160
  });
161
+ test("runConsolidation honors configurable rewrite budgets and resumes remaining candidates", async () => {
162
+ const { dbModule, wikiFs, indexManager, consolidation } = await loadModules();
163
+ const db = dbModule.getDb();
164
+ wikiFs.ensureWikiStructure();
165
+ const slugs = ["alpha-planet", "bravo-river", "charlie-forest", "delta-mountain", "echo-harbor"];
166
+ for (let i = 0; i < slugs.length; i++) {
167
+ wikiFs.writePage(`pages/topics/${slugs[i]}/index.md`, makePage(`Resumable ${i}`, `Resumable ${i}`, "2026-05-01", `Old resumable ${i}.`, `### 2026-05-13T10:00:00.000Z\n\nFresh fact ${i}.`));
168
+ }
169
+ indexManager.rebuildWikiIndex();
170
+ const seen = [];
171
+ const deps = {
172
+ now: () => new Date("2026-05-14T22:30:03.086Z"),
173
+ synthesizeTruth: async ({ pagePath }) => {
174
+ seen.push(pagePath);
175
+ return `Synthesized ${pagePath}`;
176
+ },
177
+ commitWikiChanges: async () => false,
178
+ truthRewriteBudget: 2,
179
+ };
180
+ const first = await consolidation.runConsolidationWithDeps(db, deps);
181
+ const second = await consolidation.runConsolidationWithDeps(db, deps);
182
+ const third = await consolidation.runConsolidationWithDeps(db, deps);
183
+ assert.equal(first.truthRewrites, 2);
184
+ assert.equal(second.truthRewrites, 2);
185
+ assert.equal(third.truthRewrites, 1);
186
+ assert.equal(new Set(seen).size, 5);
187
+ });
143
188
  //# sourceMappingURL=consolidation.test.js.map
@@ -1,4 +1,5 @@
1
1
  import { normalizeWikiPath } from "./path-utils.js";
2
+ import { DEFAULT_SCHEMA, load } from "js-yaml";
2
3
  const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
3
4
  const SUMMARY_MARKDOWN_RE = /(\*\*|__|[_*`~]|^\s*#+\s|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|^\s*>)/m;
4
5
  const FRONTMATTER_TEMPLATE = `---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---`;
@@ -45,13 +46,18 @@ export function parseWikiFrontmatter(content) {
45
46
  };
46
47
  }
47
48
  const parsed = { metadata: {} };
48
- for (const line of match[1].split("\n")) {
49
- const idx = line.indexOf(":");
50
- if (idx <= 0)
51
- continue;
52
- const key = line.slice(0, idx).trim();
53
- const rawValue = line.slice(idx + 1).trim();
54
- const value = parseValue(rawValue);
49
+ let yaml;
50
+ try {
51
+ yaml = load(match[1], { schema: DEFAULT_SCHEMA });
52
+ }
53
+ catch {
54
+ yaml = parseLegacyFrontmatterBlock(match[1]);
55
+ }
56
+ const record = yaml && typeof yaml === "object" && !Array.isArray(yaml)
57
+ ? yaml
58
+ : {};
59
+ for (const [key, rawValue] of Object.entries(record)) {
60
+ const value = normalizeFrontmatterValue(rawValue);
55
61
  switch (key) {
56
62
  case "title":
57
63
  case "summary":
@@ -260,22 +266,46 @@ function deriveTitleFromPath(path) {
260
266
  function formatFrontmatterMessage(reason) {
261
267
  return `Wiki page frontmatter violates the required shape: ${reason}. Use:\n${FRONTMATTER_TEMPLATE}`;
262
268
  }
263
- function parseValue(rawValue) {
269
+ function normalizeFrontmatterValue(value) {
270
+ if (value instanceof Date) {
271
+ return value.toISOString().slice(0, 10);
272
+ }
273
+ if (Array.isArray(value)) {
274
+ return value.map((item) => item instanceof Date ? item.toISOString().slice(0, 10) : String(item));
275
+ }
276
+ if (typeof value === "boolean") {
277
+ return value;
278
+ }
279
+ if (value === null || value === undefined) {
280
+ return "";
281
+ }
282
+ return String(value);
283
+ }
284
+ function parseLegacyFrontmatterBlock(block) {
285
+ const parsed = {};
286
+ for (const line of block.split("\n")) {
287
+ const idx = line.indexOf(":");
288
+ if (idx <= 0)
289
+ continue;
290
+ const key = line.slice(0, idx).trim();
291
+ const rawValue = line.slice(idx + 1).trim();
292
+ parsed[key] = parseLegacyFrontmatterValue(rawValue);
293
+ }
294
+ return parsed;
295
+ }
296
+ function parseLegacyFrontmatterValue(rawValue) {
264
297
  if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
265
298
  return rawValue
266
299
  .slice(1, -1)
267
300
  .split(",")
268
- .map((item) => stripQuotes(item.trim()))
301
+ .map((item) => item.trim().replace(/^['"]|['"]$/g, ""))
269
302
  .filter(Boolean);
270
303
  }
271
304
  if (rawValue === "true")
272
305
  return true;
273
306
  if (rawValue === "false")
274
307
  return false;
275
- return stripQuotes(rawValue);
276
- }
277
- function stripQuotes(value) {
278
- return value.replace(/^['"]|['"]$/g, "");
308
+ return rawValue.replace(/^['"]|['"]$/g, "");
279
309
  }
280
310
  function materializeProjectRulesHardFields(parsed) {
281
311
  return {
@@ -41,6 +41,30 @@ Runtime notes.
41
41
  body: "# Chapterhouse\n\nRuntime notes.\n",
42
42
  });
43
43
  });
44
+ test("parseWikiFrontmatter uses YAML semantics while preserving typed string fields", async () => {
45
+ const { parseWikiFrontmatter } = await loadFrontmatterModule();
46
+ const result = parseWikiFrontmatter(`---
47
+ title: "Quoted: Title"
48
+ summary: >
49
+ Plain folded summary
50
+ updated: 2026-05-12
51
+ tags:
52
+ - engineering
53
+ - orchestration
54
+ related:
55
+ - pages/projects/chapterhouse/index.md
56
+ autostub: false
57
+ ---
58
+
59
+ # Chapterhouse
60
+ `);
61
+ assert.equal(result.parsed.title, "Quoted: Title");
62
+ assert.equal(result.parsed.summary, "Plain folded summary\n");
63
+ assert.equal(result.parsed.updated, "2026-05-12");
64
+ assert.deepEqual(result.parsed.tags, ["engineering", "orchestration"]);
65
+ assert.deepEqual(result.parsed.related, ["pages/projects/chapterhouse/index.md"]);
66
+ assert.equal(result.parsed.autostub, false);
67
+ });
44
68
  test("parseWikiFrontmatter tolerates legacy pages without frontmatter", async () => {
45
69
  const { parseWikiFrontmatter } = await loadFrontmatterModule();
46
70
  const result = parseWikiFrontmatter("# Legacy Page\n\nStill readable.\n");
package/dist/wiki/fs.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Wiki file system primitives
3
3
  // ---------------------------------------------------------------------------
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, statSync, renameSync, openSync, fsyncSync, closeSync } from "fs";
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, statSync, lstatSync, renameSync, openSync, fsyncSync, closeSync } from "fs";
5
5
  import { join, dirname, relative, resolve, sep } from "path";
6
6
  import { WIKI_DIR, WIKI_PAGES_DIR, WIKI_SOURCES_DIR } from "../paths.js";
7
7
  import { normalizeWikiPath } from "./path-utils.js";
@@ -21,6 +21,11 @@ function getLogPath() {
21
21
  * gives readers an all-or-nothing view.
22
22
  */
23
23
  export function writeFileAtomic(fullPath, content) {
24
+ // Reject symlink at the target path to prevent TOCTOU attacks.
25
+ const existing = lstatSync(fullPath, { throwIfNoEntry: false });
26
+ if (existing?.isSymbolicLink()) {
27
+ throw new Error(`Refusing to write: target path is a symlink: ${fullPath}`);
28
+ }
24
29
  mkdirSync(dirname(fullPath), { recursive: true });
25
30
  const tmp = `${fullPath}.${process.pid}.${Date.now()}.tmp`;
26
31
  const fd = openSync(tmp, "w");
@@ -61,7 +66,7 @@ export function assertPagePath(relativePath) {
61
66
  function getInitialIndex() {
62
67
  return `# Wiki Index
63
68
 
64
- _Max's knowledge base. This file is maintained automatically._
69
+ _Chapterhouse wiki. This file is maintained automatically._
65
70
 
66
71
  Last updated: ${new Date().toISOString().slice(0, 10)}
67
72
 
@@ -124,8 +129,12 @@ export function writePage(relativePath, content) {
124
129
  /** Delete a wiki page. Returns true if the file existed and was removed. */
125
130
  export function deletePage(relativePath) {
126
131
  const fullPath = resolvePath(relativePath);
127
- if (!existsSync(fullPath))
132
+ const stat = lstatSync(fullPath, { throwIfNoEntry: false });
133
+ if (!stat)
128
134
  return false;
135
+ if (stat.isSymbolicLink()) {
136
+ throw new Error(`Refusing to delete: target path is a symlink: ${fullPath}`);
137
+ }
129
138
  unlinkSync(fullPath);
130
139
  return true;
131
140
  }
@@ -153,7 +162,10 @@ export function writeRawSource(name, content) {
153
162
  /** Read a raw source document. */
154
163
  export function readRawSource(name) {
155
164
  const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "-");
156
- const fullPath = join(WIKI_SOURCES_DIR, safeName);
165
+ const fullPath = resolve(WIKI_SOURCES_DIR, safeName);
166
+ if (!fullPath.startsWith(WIKI_SOURCES_DIR + sep)) {
167
+ throw new Error(`Source path escapes sources dir: ${name}`);
168
+ }
157
169
  if (!existsSync(fullPath))
158
170
  return undefined;
159
171
  return readFileSync(fullPath, "utf-8");
@@ -55,4 +55,88 @@ test("wiki fs rejects unsafe page paths", async () => {
55
55
  assert.throws(() => wiki.assertPagePath("sources/raw.md"), /only pages under pages\//);
56
56
  assert.throws(() => wiki.assertPagePath("pages/shared/runbooks/deploy.txt"), /must end in \.md/);
57
57
  });
58
+ // ---------------------------------------------------------------------------
59
+ // TOCTOU / symlink hardening tests
60
+ // ---------------------------------------------------------------------------
61
+ test("writeFileAtomic rejects a symlink at the target path (TOCTOU guard)", async () => {
62
+ const { symlinkSync, mkdirSync: mkdir2, writeFileSync: write2 } = await import("node:fs");
63
+ rmSync(sandboxRoot, { recursive: true, force: true });
64
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
65
+ const wiki = await loadFsModule();
66
+ wiki.ensureWikiStructure();
67
+ // Set up a real file outside the wiki and a symlink pointing to it inside the wiki.
68
+ const targetDir = join(sandboxRoot, "symlink-target");
69
+ mkdir2(targetDir, { recursive: true });
70
+ write2(join(targetDir, "outside.md"), "outside content", "utf-8");
71
+ const pageDir = join(wikiDir, "pages", "shared", "runbooks");
72
+ mkdir2(pageDir, { recursive: true });
73
+ symlinkSync(join(targetDir, "outside.md"), join(pageDir, "evil.md"));
74
+ // writeFileAtomic (via writePage) must reject the symlink.
75
+ assert.throws(() => wiki.writePage("pages/shared/runbooks/evil.md", "# Evil\n"), /Refusing to write: target path is a symlink/);
76
+ // The real file must be untouched.
77
+ assert.equal(readFileSync(join(targetDir, "outside.md"), "utf-8"), "outside content");
78
+ });
79
+ test("writeFileAtomic succeeds for a normal (non-symlink) path", async () => {
80
+ rmSync(sandboxRoot, { recursive: true, force: true });
81
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
82
+ const wiki = await loadFsModule();
83
+ wiki.ensureWikiStructure();
84
+ wiki.writePage("pages/shared/runbooks/normal.md", "# Normal\n");
85
+ assert.equal(wiki.readPage("pages/shared/runbooks/normal.md"), "# Normal\n");
86
+ });
87
+ test("writeFileAtomic allows an intermediate symlinked directory (only final target matters)", async () => {
88
+ const { symlinkSync, mkdirSync: mkdir3 } = await import("node:fs");
89
+ rmSync(sandboxRoot, { recursive: true, force: true });
90
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
91
+ const wiki = await loadFsModule();
92
+ wiki.ensureWikiStructure();
93
+ // A directory under the wiki that is itself a symlink to another dir inside the wiki.
94
+ const realSubdir = join(wikiDir, "pages", "real-runbooks");
95
+ mkdir3(realSubdir, { recursive: true });
96
+ const linkedSubdir = join(wikiDir, "pages", "linked-runbooks");
97
+ symlinkSync(realSubdir, linkedSubdir);
98
+ // Writing through the symlinked directory should succeed (intermediate is OK).
99
+ assert.doesNotThrow(() => wiki.writePage("pages/linked-runbooks/guide.md", "# Guide\n"));
100
+ });
101
+ test("deletePage rejects a symlink at the target path (TOCTOU guard)", async () => {
102
+ const { symlinkSync, mkdirSync: mkdir4, writeFileSync: write4 } = await import("node:fs");
103
+ rmSync(sandboxRoot, { recursive: true, force: true });
104
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
105
+ const wiki = await loadFsModule();
106
+ wiki.ensureWikiStructure();
107
+ const targetDir = join(sandboxRoot, "delete-target");
108
+ mkdir4(targetDir, { recursive: true });
109
+ write4(join(targetDir, "outside.md"), "keep me", "utf-8");
110
+ const pageDir = join(wikiDir, "pages", "shared", "runbooks");
111
+ mkdir4(pageDir, { recursive: true });
112
+ symlinkSync(join(targetDir, "outside.md"), join(pageDir, "poison.md"));
113
+ assert.throws(() => wiki.deletePage("pages/shared/runbooks/poison.md"), /Refusing to delete: target path is a symlink/);
114
+ // Target file must still exist.
115
+ assert.equal(readFileSync(join(targetDir, "outside.md"), "utf-8"), "keep me");
116
+ });
117
+ test("readRawSource rejects '..' path traversal — escapes sources dir", async () => {
118
+ rmSync(sandboxRoot, { recursive: true, force: true });
119
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
120
+ const wiki = await loadFsModule();
121
+ wiki.ensureWikiStructure();
122
+ // ".." survives the character whitelist (both "." chars are whitelisted) but
123
+ // resolve(WIKI_SOURCES_DIR, "..") resolves to the parent directory — containment
124
+ // check must catch this and throw.
125
+ assert.throws(() => wiki.readRawSource(".."), /Source path escapes sources dir/);
126
+ });
127
+ test("readRawSource returns undefined for a valid name that does not exist", async () => {
128
+ rmSync(sandboxRoot, { recursive: true, force: true });
129
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
130
+ const wiki = await loadFsModule();
131
+ wiki.ensureWikiStructure();
132
+ assert.equal(wiki.readRawSource("nonexistent.md"), undefined);
133
+ });
134
+ test("readRawSource reads back a file written by writeRawSource", async () => {
135
+ rmSync(sandboxRoot, { recursive: true, force: true });
136
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
137
+ const wiki = await loadFsModule();
138
+ wiki.ensureWikiStructure();
139
+ wiki.writeRawSource("test-doc.md", "# Hello Source\n");
140
+ assert.equal(wiki.readRawSource("test-doc.md"), "# Hello Source\n");
141
+ });
58
142
  //# sourceMappingURL=fs.test.js.map
@@ -3,6 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  import { getDb, isFts5Available } from "../store/db.js";
5
5
  import { childLogger } from "../util/logger.js";
6
+ import { assertWikiWriteHeld } from "./lock.js";
6
7
  import { listPages, readPage } from "./fs.js";
7
8
  import { parseWikiFrontmatter } from "./frontmatter.js";
8
9
  import { normalizeWikiPath } from "./path-utils.js";
@@ -143,6 +144,25 @@ function runWikiReindex() {
143
144
  export function rebuildWikiIndex() {
144
145
  runWikiReindex();
145
146
  }
147
+ export function refreshWikiPages(paths) {
148
+ const seen = new Set(paths.map(normalizeWikiPath));
149
+ for (const path of seen) {
150
+ try {
151
+ const content = readPage(path);
152
+ if (!content) {
153
+ removeWikiPage(path);
154
+ continue;
155
+ }
156
+ const { parsed: fm, body } = parseWikiFrontmatter(content);
157
+ const summary = buildPageSummary(fm.summary, body);
158
+ upsertWikiPage(path, fm, summary);
159
+ updateLinks(path);
160
+ }
161
+ catch (err) {
162
+ log.warn({ path, err: getErrorMessage(err) }, "Skipping wiki page during incremental reindex");
163
+ }
164
+ }
165
+ }
146
166
  /**
147
167
  * Search wiki pages using FTS5 BM25 ranking.
148
168
  * Falls back to LIKE query if FTS5 unavailable.
@@ -321,8 +341,12 @@ export function parseIndex() {
321
341
  };
322
342
  });
323
343
  }
324
- /** Add or update a page in the index. Delegates to upsertWikiPage. */
344
+ /**
345
+ * Add or update a page in the index. Callers must already hold withWikiWrite()
346
+ * so the page write and wiki_pages update stay serialized together.
347
+ */
325
348
  export function addToIndex(entry) {
349
+ assertWikiWriteHeld();
326
350
  const fm = {
327
351
  title: entry.title,
328
352
  summary: entry.summary,
@@ -332,8 +356,12 @@ export function addToIndex(entry) {
332
356
  };
333
357
  upsertWikiPage(entry.path, fm, entry.summary);
334
358
  }
335
- /** Remove an entry from the index by path. Returns true if found. */
359
+ /**
360
+ * Remove an entry from the index by path. Callers must already hold
361
+ * withWikiWrite() so file deletion and index cleanup stay serialized together.
362
+ */
336
363
  export function removeFromIndex(path) {
364
+ assertWikiWriteHeld();
337
365
  const db = getDb();
338
366
  const normalizedPath = normalizeWikiPath(path);
339
367
  const existing = db.prepare(`SELECT 1 FROM wiki_pages WHERE path = ?`).get(normalizedPath);
@@ -3,6 +3,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { resetSingletons } from "../test/helpers/reset-singletons.js";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
+ import { withWikiWrite } from "./lock.js";
6
7
  // Sandbox: every test gets a fresh CHAPTERHOUSE_HOME
7
8
  function makeSandbox() {
8
9
  const dir = mkdtempSync(join(process.cwd(), ".test-work", "wiki-idx-"));
@@ -10,17 +11,19 @@ function makeSandbox() {
10
11
  resetSingletons();
11
12
  return dir;
12
13
  }
13
- async function loadModules(sandbox) {
14
+ async function loadModules(_sandbox) {
14
15
  const nonce = `${Date.now()}-${Math.random()}`;
15
16
  const indexManager = await import(new URL(`./index-manager.js?c=${nonce}`, import.meta.url).href);
16
17
  const wikiFs = await import(new URL(`./fs.js?c=${nonce}`, import.meta.url).href);
17
18
  return { indexManager, wikiFs };
18
19
  }
19
- function resetWikiState(indexManager, wikiFs) {
20
+ async function resetWikiState(indexManager, wikiFs) {
20
21
  rmSync(wikiFs.getWikiDir(), { recursive: true, force: true });
21
- for (const entry of indexManager.parseIndex()) {
22
- indexManager.removeFromIndex(entry.path);
23
- }
22
+ await withWikiWrite(() => {
23
+ for (const entry of indexManager.parseIndex()) {
24
+ indexManager.removeFromIndex(entry.path);
25
+ }
26
+ });
24
27
  }
25
28
  async function loadModulesWithMocks(t, sandbox, options = {}) {
26
29
  const warnings = [];
@@ -106,9 +109,11 @@ test("ensureWikiIndexPopulated rebuilds from disk when wiki_pages starts empty",
106
109
  try {
107
110
  const { indexManager, wikiFs } = await loadModules(sandbox);
108
111
  wikiFs.writePage("pages/topics/rust/index.md", "---\ntitle: Rust\nsummary: Systems programming\nupdated: 2026-05-10\n---\n\n# Rust\n");
109
- for (const entry of indexManager.parseIndex()) {
110
- indexManager.removeFromIndex(entry.path);
111
- }
112
+ await withWikiWrite(() => {
113
+ for (const entry of indexManager.parseIndex()) {
114
+ indexManager.removeFromIndex(entry.path);
115
+ }
116
+ });
112
117
  assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
113
118
  const result = indexManager.ensureWikiIndexPopulated();
114
119
  assert.equal(result.reindexed, true);
@@ -155,14 +160,40 @@ test("FTS search returns results under 50ms", async () => {
155
160
  rmSync(sandbox, { recursive: true, force: true });
156
161
  }
157
162
  });
158
- test("removeFromIndex removes from wiki_pages", async () => {
163
+ test("addToIndex requires the wiki write lock", async () => {
164
+ const sandbox = makeSandbox();
165
+ try {
166
+ const { indexManager } = await loadModules(sandbox);
167
+ assert.throws(() => indexManager.addToIndex({
168
+ path: "pages/people/test/index.md",
169
+ title: "Test Person",
170
+ summary: "A test",
171
+ section: "People",
172
+ }), /withWikiWrite/);
173
+ }
174
+ finally {
175
+ rmSync(sandbox, { recursive: true, force: true });
176
+ }
177
+ });
178
+ test("removeFromIndex requires the wiki write lock", async () => {
179
+ const sandbox = makeSandbox();
180
+ try {
181
+ const { indexManager } = await loadModules(sandbox);
182
+ indexManager.upsertWikiPage("pages/people/test/index.md", { title: "Test Person", summary: "A test", tags: [], updated: "2026-05-01", metadata: {} }, "A test");
183
+ assert.throws(() => indexManager.removeFromIndex("pages/people/test/index.md"), /withWikiWrite/);
184
+ }
185
+ finally {
186
+ rmSync(sandbox, { recursive: true, force: true });
187
+ }
188
+ });
189
+ test("removeFromIndex removes from wiki_pages within the wiki write lock", async () => {
159
190
  const sandbox = makeSandbox();
160
191
  try {
161
192
  const { indexManager } = await loadModules(sandbox);
162
193
  indexManager.upsertWikiPage("pages/people/test/index.md", { title: "Test Person", summary: "A test", tags: [], updated: "2026-05-01", metadata: {} }, "A test");
163
194
  const before = indexManager.wikiSearch("Test Person");
164
195
  assert.ok(before.length > 0, "Should exist before removal");
165
- const removed = indexManager.removeFromIndex("pages/people/test/index.md");
196
+ const removed = await withWikiWrite(() => indexManager.removeFromIndex("pages/people/test/index.md"));
166
197
  assert.equal(removed, true);
167
198
  const after = indexManager.wikiSearch("Test Person");
168
199
  assert.equal(after.length, 0, "Should not exist after removal");
@@ -208,7 +239,7 @@ test("reindexWikiPages skips unreadable pages and continues indexing others", as
208
239
  const sandbox = makeSandbox();
209
240
  try {
210
241
  const { indexManager, wikiFs, warnings } = await loadModulesWithMocks(t, sandbox);
211
- resetWikiState(indexManager, wikiFs);
242
+ await resetWikiState(indexManager, wikiFs);
212
243
  const unreadablePath = join(wikiFs.getWikiDir(), "pages", "topics", "blocked", "index.md");
213
244
  wikiFs.writePage("pages/topics/alpha/index.md", "---\ntitle: Alpha\nsummary: First topic\nupdated: 2026-05-01\n---\n\n# Alpha\n");
214
245
  wikiFs.writePage("pages/topics/blocked/index.md", "---\ntitle: Blocked\nsummary: Unreadable topic\nupdated: 2026-05-02\n---\n\n# Blocked\n");
@@ -237,7 +268,7 @@ test("reindexWikiPages skips malformed pages and continues indexing others", asy
237
268
  const sandbox = makeSandbox();
238
269
  try {
239
270
  const { indexManager, wikiFs, warnings } = await loadModulesWithMocks(t, sandbox, { malformedMarker: "UNPARSEABLE" });
240
- resetWikiState(indexManager, wikiFs);
271
+ await resetWikiState(indexManager, wikiFs);
241
272
  wikiFs.writePage("pages/topics/alpha/index.md", "---\ntitle: Alpha\nsummary: First topic\nupdated: 2026-05-01\n---\n\n# Alpha\n");
242
273
  wikiFs.writePage("pages/topics/bad/index.md", "---\ntitle: Bad\nsummary: Broken topic\nupdated: 2026-05-02\n---\n\nUNPARSEABLE\n");
243
274
  wikiFs.writePage("pages/topics/gamma/index.md", "---\ntitle: Gamma\nsummary: Third topic\nupdated: 2026-05-03\n---\n\n# Gamma\n");
@@ -137,7 +137,7 @@ async function parseRepo(repoUrl) {
137
137
  async function extractEntities(text, topic) {
138
138
  // Skip entity extraction if no auth token is configured
139
139
  const { config } = await import("../config.js");
140
- const token = config.copilotAuthToken || process.env.COPILOT_TOKEN || process.env.GITHUB_TOKEN;
140
+ const token = config.copilotAuthToken;
141
141
  if (!token) {
142
142
  log.debug("No Copilot auth token configured, skipping entity extraction");
143
143
  return { entities: [], relationships: [] };
package/dist/wiki/lock.js CHANGED
@@ -6,19 +6,29 @@
6
6
  // and the async episode-writer overlap, every mutation must run through
7
7
  // withWikiWrite(). Reads do NOT need to acquire the lock — they are protected
8
8
  // by atomic file replacement at the FS level.
9
+ //
10
+ // Low-level index mutations assert this lock is owned by the current async call
11
+ // chain so page writes and wiki_pages updates cannot silently interleave.
9
12
  // ---------------------------------------------------------------------------
13
+ import { AsyncLocalStorage } from "node:async_hooks";
10
14
  let chain = Promise.resolve();
15
+ const wikiWriteContext = new AsyncLocalStorage();
11
16
  /**
12
17
  * Run an async wiki mutation under the global write lock.
13
18
  * Calls are serialized FIFO. Errors propagate to the caller but do not
14
19
  * break the chain for subsequent writers.
15
20
  */
16
21
  export function withWikiWrite(fn) {
17
- const next = chain.then(() => fn(), () => fn());
22
+ const next = chain.then(() => wikiWriteContext.run(true, fn), () => wikiWriteContext.run(true, fn));
18
23
  // Keep the chain alive even if `next` rejects so the next caller can run.
19
24
  chain = next.catch(() => undefined);
20
25
  return next;
21
26
  }
27
+ export function assertWikiWriteHeld() {
28
+ if (wikiWriteContext.getStore() !== true) {
29
+ throw new Error("Wiki index mutations must run within withWikiWrite().");
30
+ }
31
+ }
22
32
  /** For tests/diagnostics: wait for the current write queue to drain. */
23
33
  export function drainWikiWrites() {
24
34
  return chain.then(() => undefined, () => undefined);
@@ -3,6 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  import { appendFileSync, existsSync, readFileSync, renameSync } from "fs";
5
5
  import { join } from "path";
6
+ import { config } from "../config.js";
6
7
  import { WIKI_PAGES_DIR } from "../paths.js";
7
8
  import { ensureWikiStructure, writeFileAtomic } from "./fs.js";
8
9
  export const ACTION_LOG_PATH = "pages/_meta/log.md";
@@ -55,12 +56,6 @@ function extractEntries(content) {
55
56
  .filter((chunk) => chunk.startsWith("## ["));
56
57
  }
57
58
  function resolveAgentName() {
58
- const candidates = [
59
- process.env.CHAPTERHOUSE_SESSION_AGENT_NAME,
60
- process.env.CHAPTERHOUSE_AGENT_NAME,
61
- process.env.COPILOT_AGENT_NAME,
62
- process.env.AGENT_NAME,
63
- ];
64
- return candidates.find((candidate) => candidate && candidate.trim().length > 0)?.trim() ?? "unknown";
59
+ return config.agentNameCandidates[0] ?? "unknown";
65
60
  }
66
61
  //# sourceMappingURL=log-manager.js.map
@@ -33,6 +33,7 @@ const CATEGORY_MAP = {
33
33
  export function migrateMemoriesToWiki() {
34
34
  ensureWikiStructure();
35
35
  const db = getDb();
36
+ ensureWikiMigrationVersionTable(db);
36
37
  const rows = db.prepare(`SELECT id, category, content, source, created_at FROM memories ORDER BY category, id`).all();
37
38
  if (rows.length === 0) {
38
39
  setState(MIGRATION_KEY, "true");
@@ -73,30 +74,26 @@ export function migrateMemoriesToWiki() {
73
74
  lines.push("");
74
75
  // Check if a page already exists (avoid clobbering manual content)
75
76
  const existing = readPage(mapping.path);
76
- // Idempotency marker: if the migration block was already appended, skip the
77
- // append so re-runs don't duplicate bullets.
78
- const MIGRATE_MARKER = `<!-- migrate:${category}:v1 -->`;
77
+ backfillMemoryMigrationVersionFromLegacyMarker(db, category, mapping.path, existing);
78
+ if (hasWikiMigrationVersion(db, category, 1)) {
79
+ const entry = {
80
+ path: mapping.path,
81
+ title: mapping.title,
82
+ summary: `${items.length} ${category} memories (already migrated)`,
83
+ section: mapping.section,
84
+ };
85
+ addToIndex(entry);
86
+ continue;
87
+ }
79
88
  if (existing) {
80
- if (existing.includes(MIGRATE_MARKER)) {
81
- // Already migrated; just refresh the index entry.
82
- const entry = {
83
- path: mapping.path,
84
- title: mapping.title,
85
- summary: `${items.length} ${category} memories (already migrated)`,
86
- section: mapping.section,
87
- };
88
- addToIndex(entry);
89
- continue;
90
- }
91
89
  // Extract only the bullet-point items to append
92
90
  const bulletLines = lines.filter((l) => l.startsWith("- "));
93
- writePage(mapping.path, existing + `\n${MIGRATE_MARKER}\n## Migrated Memories\n\n` + bulletLines.join("\n") + "\n");
91
+ writePage(mapping.path, existing + `\n## Migrated Memories\n\n` + bulletLines.join("\n") + "\n");
94
92
  }
95
93
  else {
96
- // Embed the marker in fresh pages too so future re-runs are no-ops.
97
- lines.splice(lines.length - 1, 0, MIGRATE_MARKER);
98
94
  writePage(mapping.path, lines.join("\n"));
99
95
  }
96
+ setWikiMigrationVersion(db, category, 1);
100
97
  // Update index
101
98
  const entry = {
102
99
  path: mapping.path,
@@ -113,6 +110,36 @@ export function migrateMemoriesToWiki() {
113
110
  log.info({ total, pageCount: Object.keys(grouped).length }, "Wiki migration complete");
114
111
  return total;
115
112
  }
113
+ function ensureWikiMigrationVersionTable(db) {
114
+ db.exec(`
115
+ CREATE TABLE IF NOT EXISTS wiki_migration_versions (
116
+ name TEXT NOT NULL,
117
+ version INTEGER NOT NULL,
118
+ applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
119
+ PRIMARY KEY (name, version)
120
+ )
121
+ `);
122
+ }
123
+ function hasWikiMigrationVersion(db, category, version) {
124
+ const row = db.prepare(`SELECT 1 FROM wiki_migration_versions WHERE name = ? AND version = ?`).get(`memory:${category}`, version);
125
+ return Boolean(row);
126
+ }
127
+ function setWikiMigrationVersion(db, category, version) {
128
+ db.prepare(`
129
+ INSERT OR IGNORE INTO wiki_migration_versions (name, version)
130
+ VALUES (?, ?)
131
+ `).run(`memory:${category}`, version);
132
+ }
133
+ function backfillMemoryMigrationVersionFromLegacyMarker(db, category, path, existing) {
134
+ if (existing?.includes(`<!-- migrate:${category}:v1 -->`)) {
135
+ setWikiMigrationVersion(db, category, 1);
136
+ return;
137
+ }
138
+ const markerPage = readPage(path);
139
+ if (markerPage?.includes(`<!-- migrate:${category}:v1 -->`)) {
140
+ setWikiMigrationVersion(db, category, 1);
141
+ }
142
+ }
116
143
  // ---------------------------------------------------------------------------
117
144
  // One-time reorganization: flat dump pages → entity pages
118
145
  // ---------------------------------------------------------------------------