chapterhouse 0.7.0 → 0.8.1

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 (81) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/korg.js +34 -0
  3. package/dist/api/korg.test.js +42 -0
  4. package/dist/api/server.js +238 -2
  5. package/dist/api/server.test.js +199 -0
  6. package/dist/config.js +28 -0
  7. package/dist/config.test.js +20 -0
  8. package/dist/copilot/agents.js +3 -4
  9. package/dist/copilot/agents.test.js +12 -1
  10. package/dist/copilot/orchestrator.js +12 -1
  11. package/dist/copilot/orchestrator.test.js +3 -7
  12. package/dist/copilot/system-message.js +12 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +193 -375
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +80 -59
  17. package/dist/copilot/turn-event-log-env.test.js +11 -15
  18. package/dist/daemon.js +19 -0
  19. package/dist/memory/decisions.js +6 -5
  20. package/dist/memory/entities.js +20 -9
  21. package/dist/memory/eot.js +30 -8
  22. package/dist/memory/eot.test.js +220 -6
  23. package/dist/memory/hooks.js +151 -0
  24. package/dist/memory/hooks.test.js +325 -0
  25. package/dist/memory/hot-tier.js +37 -0
  26. package/dist/memory/hot-tier.test.js +30 -0
  27. package/dist/memory/housekeeping-scheduler.js +35 -0
  28. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  29. package/dist/memory/inbox.js +10 -0
  30. package/dist/memory/index.js +3 -1
  31. package/dist/memory/migration.js +244 -0
  32. package/dist/memory/migration.test.js +108 -0
  33. package/dist/memory/reflect.js +273 -0
  34. package/dist/memory/reflect.test.js +254 -0
  35. package/dist/paths.js +31 -11
  36. package/dist/store/db.js +187 -4
  37. package/dist/store/db.test.js +66 -2
  38. package/dist/test/helpers/reset-singletons.js +8 -0
  39. package/dist/test/helpers/reset-singletons.test.js +37 -0
  40. package/dist/test/setup-env.js +9 -1
  41. package/dist/wiki/consolidation.js +641 -0
  42. package/dist/wiki/consolidation.test.js +143 -0
  43. package/dist/wiki/frontmatter.js +48 -0
  44. package/dist/wiki/frontmatter.test.js +42 -0
  45. package/dist/wiki/fs.js +22 -13
  46. package/dist/wiki/index-manager.js +305 -330
  47. package/dist/wiki/index-manager.test.js +265 -144
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/log-manager.js +8 -5
  53. package/dist/wiki/log-manager.test.js +4 -0
  54. package/dist/wiki/migrate-topics.test.js +16 -6
  55. package/dist/wiki/scheduler.js +118 -0
  56. package/dist/wiki/scheduler.test.js +64 -0
  57. package/dist/wiki/timeline.js +51 -0
  58. package/dist/wiki/timeline.test.js +65 -0
  59. package/dist/wiki/topic-structure.js +1 -1
  60. package/package.json +1 -1
  61. package/skills/pkb-ideas/SKILL.md +78 -0
  62. package/skills/pkb-ideas/_meta.json +4 -0
  63. package/skills/pkb-org/SKILL.md +82 -0
  64. package/skills/pkb-org/_meta.json +4 -0
  65. package/skills/pkb-people/SKILL.md +74 -0
  66. package/skills/pkb-people/_meta.json +4 -0
  67. package/skills/pkb-research/SKILL.md +83 -0
  68. package/skills/pkb-research/_meta.json +4 -0
  69. package/skills/pkb-source/SKILL.md +38 -0
  70. package/skills/pkb-source/_meta.json +4 -0
  71. package/skills/wiki-conventions/SKILL.md +5 -5
  72. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  73. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  74. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  75. package/web/dist/index.html +2 -2
  76. package/dist/wiki/context.js +0 -138
  77. package/dist/wiki/fix.js +0 -335
  78. package/dist/wiki/fix.test.js +0 -350
  79. package/dist/wiki/lint.js +0 -451
  80. package/dist/wiki/lint.test.js +0 -329
  81. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -1,160 +1,281 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdirSync, rmSync } from "node:fs";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { resetSingletons } from "../test/helpers/reset-singletons.js";
3
4
  import { join } from "node:path";
4
5
  import test from "node:test";
5
- const repoRoot = process.cwd();
6
- const sandboxRoot = join(repoRoot, ".test-work", `wiki-index-${process.pid}`);
7
- process.env.CHAPTERHOUSE_HOME = sandboxRoot;
8
- async function loadModules() {
6
+ // Sandbox: every test gets a fresh CHAPTERHOUSE_HOME
7
+ function makeSandbox() {
8
+ const dir = mkdtempSync(join(process.cwd(), ".test-work", "wiki-idx-"));
9
+ process.env.CHAPTERHOUSE_HOME = dir;
10
+ resetSingletons();
11
+ return dir;
12
+ }
13
+ async function loadModules(sandbox) {
9
14
  const nonce = `${Date.now()}-${Math.random()}`;
10
- const indexManager = await import(new URL(`./index-manager.js?case=${nonce}`, import.meta.url).href);
11
- const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
15
+ const indexManager = await import(new URL(`./index-manager.js?c=${nonce}`, import.meta.url).href);
16
+ const wikiFs = await import(new URL(`./fs.js?c=${nonce}`, import.meta.url).href);
12
17
  return { indexManager, wikiFs };
13
18
  }
14
- function resetSandbox() {
15
- mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
16
- rmSync(sandboxRoot, { recursive: true, force: true });
19
+ function resetWikiState(indexManager, wikiFs) {
20
+ rmSync(wikiFs.getWikiDir(), { recursive: true, force: true });
21
+ for (const entry of indexManager.parseIndex()) {
22
+ indexManager.removeFromIndex(entry.path);
23
+ }
17
24
  }
18
- test.beforeEach(() => {
19
- resetSandbox();
25
+ async function loadModulesWithMocks(t, sandbox, options = {}) {
26
+ const warnings = [];
27
+ const infos = [];
28
+ t.mock.module("../util/logger.js", {
29
+ namedExports: {
30
+ childLogger: () => ({
31
+ info: (obj, msg) => infos.push({ obj, msg }),
32
+ warn: (obj, msg) => warnings.push({ obj, msg }),
33
+ error: () => { },
34
+ }),
35
+ },
36
+ });
37
+ if (options.malformedMarker) {
38
+ const actualFrontmatter = await import(new URL(`./frontmatter.js?actual=${Date.now()}-${Math.random()}`, import.meta.url).href);
39
+ t.mock.module("./frontmatter.js", {
40
+ namedExports: {
41
+ parseWikiFrontmatter: (content) => {
42
+ if (content.includes(options.malformedMarker)) {
43
+ throw new Error("Malformed wiki page");
44
+ }
45
+ return actualFrontmatter.parseWikiFrontmatter(content);
46
+ },
47
+ },
48
+ });
49
+ }
50
+ return { ...(await loadModules(sandbox)), warnings, infos };
51
+ }
52
+ test.before(() => {
53
+ mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
20
54
  });
21
- test.after(() => {
22
- rmSync(sandboxRoot, { recursive: true, force: true });
55
+ test("wikiSearch returns FTS5 results for matching query", async () => {
56
+ const sandbox = makeSandbox();
57
+ try {
58
+ const { indexManager, wikiFs } = await loadModules(sandbox);
59
+ wikiFs.writePage("pages/topics/rust/index.md", "---\ntitle: Rust Programming\nsummary: Systems programming with async support\ntags: [rust, async]\nupdated: 2026-05-01\n---\n\n# Rust\n\nSystems language.\n");
60
+ wikiFs.writePage("pages/topics/typescript/index.md", "---\ntitle: TypeScript\nsummary: Typed JavaScript for large projects\ntags: [ts, web]\nupdated: 2026-05-02\n---\n\n# TypeScript\n\nJS with types.\n");
61
+ indexManager.rebuildWikiIndex();
62
+ const results = indexManager.wikiSearch("rust async");
63
+ assert.ok(results.length > 0, "Should return results for 'rust async'");
64
+ assert.ok(results.some((r) => r.path === "pages/topics/rust/index.md"), "Should include rust page");
65
+ }
66
+ finally {
67
+ rmSync(sandbox, { recursive: true, force: true });
68
+ }
23
69
  });
24
- test("parseIndex reads sections, summaries, tags, and updated dates", async () => {
25
- const { indexManager, wikiFs } = await loadModules();
26
- wikiFs.writeIndexFile(`# Wiki Index\n\n## People\n\n- [Ada Lovelace](pages/people/ada.md) — Platform owner | tags: engineer, compiler | updated: 2026-05-01\n\n## Projects\n\n- [Roadmap](pages/projects/roadmap.md) - Shared priorities\n`);
27
- assert.deepEqual(indexManager.parseIndex(), [
28
- {
29
- path: "pages/people/ada.md",
30
- title: "Ada Lovelace",
31
- summary: "Platform owner",
32
- section: "People",
33
- tags: ["engineer", "compiler"],
34
- updated: "2026-05-01",
35
- },
36
- {
37
- path: "pages/projects/roadmap.md",
38
- title: "Roadmap",
39
- summary: "Shared priorities",
40
- section: "Projects",
41
- tags: undefined,
42
- updated: undefined,
43
- },
44
- ]);
70
+ test("wikiSearch empty query returns most recently updated pages", async () => {
71
+ const sandbox = makeSandbox();
72
+ try {
73
+ const { indexManager, wikiFs } = await loadModules(sandbox);
74
+ wikiFs.writePage("pages/topics/alpha/index.md", "---\ntitle: Alpha\nsummary: First topic\nupdated: 2026-01-01\n---\n\n# Alpha\n");
75
+ wikiFs.writePage("pages/topics/beta/index.md", "---\ntitle: Beta\nsummary: Second topic\nupdated: 2026-05-14\n---\n\n# Beta\n");
76
+ indexManager.rebuildWikiIndex();
77
+ const results = indexManager.wikiSearch("", 10);
78
+ assert.ok(results.length >= 2, "Should return pages for empty query");
79
+ // Most recent first
80
+ const betaIdx = results.findIndex((r) => r.path === "pages/topics/beta/index.md");
81
+ const alphaIdx = results.findIndex((r) => r.path === "pages/topics/alpha/index.md");
82
+ assert.ok(betaIdx < alphaIdx, "More recently updated page should come first");
83
+ }
84
+ finally {
85
+ rmSync(sandbox, { recursive: true, force: true });
86
+ }
45
87
  });
46
- test("buildIndexEntryForPage treats frontmatter summary as the canonical index summary", async () => {
47
- const { indexManager, wikiFs } = await loadModules();
48
- wikiFs.writePage("pages/shared/runbooks/deploy.md", `---\ntitle: Deploy Runbook\nsummary: Production deployment checklist\ntags: [ops, release]\nupdated: 2026-05-04\n---\n\n# Deploy\n\n${"Deploy carefully ".repeat(20)}\n`);
49
- const entry = indexManager.buildIndexEntryForPage("pages/shared/runbooks/deploy.md");
50
- assert.deepEqual(entry, {
51
- path: "pages/shared/runbooks/deploy.md",
52
- title: "Deploy Runbook",
53
- summary: "Production deployment checklist",
54
- section: "Knowledge",
55
- tags: ["ops", "release"],
56
- updated: "2026-05-04",
57
- });
88
+ test("rebuildWikiIndex populates wiki_pages from filesystem", async () => {
89
+ const sandbox = makeSandbox();
90
+ try {
91
+ const { indexManager, wikiFs } = await loadModules(sandbox);
92
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", "---\ntitle: Chapterhouse\nsummary: AI orchestrator\ntags: [ai, orchestration]\nupdated: 2026-05-10\n---\n\n# Chapterhouse\n");
93
+ wikiFs.writePage("pages/projects/chapterhouse/decisions.md", "---\ntitle: Decisions\nsummary: Architectural decisions\nupdated: 2026-05-09\n---\n\n# Decisions\n");
94
+ indexManager.rebuildWikiIndex();
95
+ const entries = indexManager.parseIndex();
96
+ const paths = entries.map((e) => e.path).sort();
97
+ assert.ok(paths.includes("pages/projects/chapterhouse/index.md"), "Should include index.md");
98
+ assert.ok(paths.includes("pages/projects/chapterhouse/decisions.md"), "Should include decisions.md");
99
+ }
100
+ finally {
101
+ rmSync(sandbox, { recursive: true, force: true });
102
+ }
58
103
  });
59
- test("parseIndex self-heals an empty index from on-disk pages", async () => {
60
- const { indexManager, wikiFs } = await loadModules();
61
- const today = new Date().toISOString().slice(0, 10);
62
- wikiFs.writePage("pages/team/vision.md", "# Vision\n\nShared direction for the team.\n");
63
- wikiFs.writeIndexFile("# Wiki Index\n\n");
64
- const entries = indexManager.parseIndex();
65
- assert.deepEqual(entries, [
66
- {
67
- path: "pages/index.md",
68
- title: "Wiki",
69
- summary: "Index of all wiki pages.",
70
- section: "Knowledge",
71
- tags: undefined,
72
- updated: today,
73
- },
74
- {
75
- path: "pages/team/vision.md",
76
- title: "Vision",
77
- summary: "Shared direction for the team.",
78
- section: "Knowledge",
79
- tags: undefined,
80
- updated: undefined,
81
- },
82
- ]);
83
- assert.match(wikiFs.readIndexFile(), /\[Vision\]\(pages\/team\/vision\.md\)/);
104
+ test("ensureWikiIndexPopulated rebuilds from disk when wiki_pages starts empty", async () => {
105
+ const sandbox = makeSandbox();
106
+ try {
107
+ const { indexManager, wikiFs } = await loadModules(sandbox);
108
+ 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
+ assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
113
+ const result = indexManager.ensureWikiIndexPopulated();
114
+ assert.equal(result.reindexed, true);
115
+ assert.ok(result.diskPageCount >= 1);
116
+ assert.ok(result.indexedPageCount >= 1);
117
+ assert.ok(indexManager.parseIndex().some((entry) => entry.path === "pages/topics/rust/index.md"));
118
+ }
119
+ finally {
120
+ rmSync(sandbox, { recursive: true, force: true });
121
+ }
84
122
  });
85
- test("the index renders entity categories as topic groups with nested facet pages", async () => {
86
- const { indexManager, wikiFs } = await loadModules();
87
- wikiFs.writePage("pages/projects/chapterhouse/index.md", "---\ntitle: Chapterhouse\nupdated: 2026-05-09\n---\n\n# Chapterhouse\n\nThe per-session orchestrator.\n");
88
- wikiFs.writePage("pages/projects/chapterhouse/decisions.md", "---\ntitle: Chapterhouse Decisions\nupdated: 2026-05-09\n---\n\n# Decisions\n\nUse SSE for streaming.\n");
89
- wikiFs.writePage("pages/preferences.md", "---\ntitle: Preferences\n---\n\n# Preferences\n\nDark mode.\n");
90
- indexManager.rebuildIndexFromPages();
91
- const index = wikiFs.readIndexFile();
92
- assert.match(index, /## Projects/);
93
- assert.match(index, /^- \[Chapterhouse\]\(pages\/projects\/chapterhouse\/index\.md\) /m);
94
- assert.match(index, /^ {2}- \[Chapterhouse Decisions\]\(pages\/projects\/chapterhouse\/decisions\.md\) — /m);
95
- assert.match(index, /## Preferences/);
96
- // Indented facet bullets must still round-trip through parseIndex.
97
- const paths = indexManager.parseIndex().map((entry) => entry.path).sort();
98
- assert.deepEqual(paths, [
99
- "pages/preferences.md",
100
- "pages/projects/chapterhouse/decisions.md",
101
- "pages/projects/chapterhouse/index.md",
102
- ]);
123
+ test("upsertWikiPage inserts and updates correctly", async () => {
124
+ const sandbox = makeSandbox();
125
+ try {
126
+ const { indexManager } = await loadModules(sandbox);
127
+ indexManager.upsertWikiPage("pages/people/ada/index.md", { title: "Ada Lovelace", summary: "Mathematician", tags: ["math"], updated: "2026-05-01", metadata: {} }, "First programmer");
128
+ const results = indexManager.wikiSearch("Ada");
129
+ assert.ok(results.some((r) => r.title === "Ada Lovelace"), "Should find Ada");
130
+ // Update
131
+ indexManager.upsertWikiPage("pages/people/ada/index.md", { title: "Ada Lovelace", summary: "Mathematician and programmer", tags: ["math", "history"], updated: "2026-05-02", metadata: {} }, "First programmer and mathematician");
132
+ const updated = indexManager.wikiSearch("programmer");
133
+ assert.ok(updated.length > 0, "Should find updated page");
134
+ }
135
+ finally {
136
+ rmSync(sandbox, { recursive: true, force: true });
137
+ }
103
138
  });
104
- test("searchIndex ranks strong metadata matches and falls back to page bodies", async () => {
105
- const { indexManager, wikiFs } = await loadModules();
106
- wikiFs.writePage("pages/team/api.md", "# API\n\nObservability budget and telemetry plans.\n");
107
- wikiFs.writePage("pages/team/ops.md", "# Ops\n\nDaily operational notes.\n");
108
- indexManager.writeIndex([
109
- {
110
- path: "pages/team/api.md",
111
- title: "API",
112
- summary: "Status of the platform",
113
- section: "Team",
114
- tags: ["api"],
115
- updated: new Date().toISOString().slice(0, 10),
116
- },
117
- {
118
- path: "pages/team/ops.md",
119
- title: "Operations",
120
- summary: "Runbooks and incident work",
121
- section: "Team",
122
- },
123
- ]);
124
- const metadataHit = indexManager.searchIndex("api", 1);
125
- const bodyFallback = indexManager.searchIndex("telemetry", 1);
126
- assert.deepEqual(metadataHit.map((entry) => entry.path), ["pages/team/api.md"]);
127
- assert.deepEqual(bodyFallback.map((entry) => entry.path), ["pages/team/api.md"]);
139
+ test("FTS search returns results under 50ms", async () => {
140
+ const sandbox = makeSandbox();
141
+ try {
142
+ const { indexManager, wikiFs } = await loadModules(sandbox);
143
+ // Populate with 20 pages
144
+ for (let i = 0; i < 20; i++) {
145
+ wikiFs.writePage(`pages/topics/topic-${i}/index.md`, `---\ntitle: Topic ${i}\nsummary: Description for topic ${i} covering various subjects\ntags: [topic${i}]\nupdated: 2026-05-01\n---\n\n# Topic ${i}\n\nContent.\n`);
146
+ }
147
+ indexManager.rebuildWikiIndex();
148
+ const start = Date.now();
149
+ const results = indexManager.wikiSearch("topic description");
150
+ const elapsed = Date.now() - start;
151
+ assert.ok(results.length > 0, "Should return results");
152
+ assert.ok(elapsed < 50, `FTS search should complete in <50ms, took ${elapsed}ms`);
153
+ }
154
+ finally {
155
+ rmSync(sandbox, { recursive: true, force: true });
156
+ }
128
157
  });
129
- test("addToIndex, removeFromIndex, and getIndexSummary keep the catalog in sync", async () => {
130
- const { indexManager } = await loadModules();
131
- const today = new Date().toISOString().slice(0, 10);
132
- indexManager.addToIndex({
133
- path: "pages/people/ada.md",
134
- title: "Ada Lovelace",
135
- summary: "Owns release quality",
136
- section: "People",
137
- tags: ["qa"],
138
- updated: "2026-05-05",
139
- });
140
- indexManager.addToIndex({
141
- path: "pages/projects/launch.md",
142
- title: "Launch",
143
- summary: "Tracks release milestones",
144
- section: "Projects",
145
- });
146
- indexManager.addToIndex({
147
- path: "pages/people/ada.md",
148
- title: "Ada Lovelace",
149
- summary: "Owns regression coverage",
150
- section: "People",
151
- tags: ["qa", "testing"],
152
- updated: "2026-05-06",
153
- });
154
- assert.equal(indexManager.removeFromIndex("pages/projects/launch.md"), true);
155
- assert.equal(indexManager.removeFromIndex("pages/projects/missing.md"), false);
156
- const summary = indexManager.getIndexSummary();
157
- assert.match(summary, /\*\*People\*\*: Ada Lovelace: Owns regression coverage \[qa, testing\] \(2026-05-06\)/);
158
- assert.match(summary, new RegExp(`\\*\\*Index\\*\\*: Wiki: Index of all wiki pages\\. \\(${today}\\)`));
158
+ test("removeFromIndex removes from wiki_pages", async () => {
159
+ const sandbox = makeSandbox();
160
+ try {
161
+ const { indexManager } = await loadModules(sandbox);
162
+ indexManager.upsertWikiPage("pages/people/test/index.md", { title: "Test Person", summary: "A test", tags: [], updated: "2026-05-01", metadata: {} }, "A test");
163
+ const before = indexManager.wikiSearch("Test Person");
164
+ assert.ok(before.length > 0, "Should exist before removal");
165
+ const removed = indexManager.removeFromIndex("pages/people/test/index.md");
166
+ assert.equal(removed, true);
167
+ const after = indexManager.wikiSearch("Test Person");
168
+ assert.equal(after.length, 0, "Should not exist after removal");
169
+ }
170
+ finally {
171
+ rmSync(sandbox, { recursive: true, force: true });
172
+ }
173
+ });
174
+ test("searchIndex delegates to wikiSearch and returns IndexEntry shape", async () => {
175
+ const sandbox = makeSandbox();
176
+ try {
177
+ const { indexManager, wikiFs } = await loadModules(sandbox);
178
+ wikiFs.writePage("pages/team/api.md", "---\ntitle: API Docs\nsummary: API documentation\ntags: [api]\nupdated: 2026-05-01\n---\n\n# API\n");
179
+ indexManager.rebuildWikiIndex();
180
+ const results = indexManager.searchIndex("api");
181
+ assert.ok(results.length > 0);
182
+ assert.ok("section" in results[0], "Should have section field");
183
+ assert.ok("title" in results[0], "Should have title field");
184
+ }
185
+ finally {
186
+ rmSync(sandbox, { recursive: true, force: true });
187
+ }
188
+ });
189
+ test("rebuildWikiIndex removes stale entries not on disk", async () => {
190
+ const sandbox = makeSandbox();
191
+ try {
192
+ const { indexManager, wikiFs } = await loadModules(sandbox);
193
+ wikiFs.writePage("pages/topics/keep/index.md", "---\ntitle: Keep\nsummary: Keep this\nupdated: 2026-05-01\n---\n\n# Keep\n");
194
+ // Insert stale entry directly
195
+ indexManager.upsertWikiPage("pages/topics/stale/index.md", { title: "Stale", summary: "Should be removed", tags: [], updated: "2026-01-01", metadata: {} }, "Stale");
196
+ // Rebuild syncs disk → DB
197
+ indexManager.rebuildWikiIndex();
198
+ const entries = indexManager.parseIndex();
199
+ const paths = entries.map((e) => e.path);
200
+ assert.ok(paths.includes("pages/topics/keep/index.md"), "Should keep on-disk page");
201
+ assert.ok(!paths.includes("pages/topics/stale/index.md"), "Should remove stale entry");
202
+ }
203
+ finally {
204
+ rmSync(sandbox, { recursive: true, force: true });
205
+ }
206
+ });
207
+ test("reindexWikiPages skips unreadable pages and continues indexing others", async (t) => {
208
+ const sandbox = makeSandbox();
209
+ try {
210
+ const { indexManager, wikiFs, warnings } = await loadModulesWithMocks(t, sandbox);
211
+ resetWikiState(indexManager, wikiFs);
212
+ const unreadablePath = join(wikiFs.getWikiDir(), "pages", "topics", "blocked", "index.md");
213
+ wikiFs.writePage("pages/topics/alpha/index.md", "---\ntitle: Alpha\nsummary: First topic\nupdated: 2026-05-01\n---\n\n# Alpha\n");
214
+ wikiFs.writePage("pages/topics/blocked/index.md", "---\ntitle: Blocked\nsummary: Unreadable topic\nupdated: 2026-05-02\n---\n\n# Blocked\n");
215
+ wikiFs.writePage("pages/topics/gamma/index.md", "---\ntitle: Gamma\nsummary: Third topic\nupdated: 2026-05-03\n---\n\n# Gamma\n");
216
+ chmodSync(unreadablePath, 0o000);
217
+ try {
218
+ const result = indexManager.reindexWikiPages();
219
+ const paths = indexManager.parseIndex().map((entry) => entry.path).sort();
220
+ assert.deepEqual(paths, [
221
+ "pages/topics/alpha/index.md",
222
+ "pages/topics/gamma/index.md",
223
+ ]);
224
+ assert.equal(result.diskPageCount, 3);
225
+ assert.equal(result.indexedPageCount, 2);
226
+ assert.ok(warnings.some((entry) => entry.obj.path === "pages/topics/blocked/index.md"), "Expected a warning for the unreadable page");
227
+ }
228
+ finally {
229
+ chmodSync(unreadablePath, 0o644);
230
+ }
231
+ }
232
+ finally {
233
+ rmSync(sandbox, { recursive: true, force: true });
234
+ }
235
+ });
236
+ test("reindexWikiPages skips malformed pages and continues indexing others", async (t) => {
237
+ const sandbox = makeSandbox();
238
+ try {
239
+ const { indexManager, wikiFs, warnings } = await loadModulesWithMocks(t, sandbox, { malformedMarker: "UNPARSEABLE" });
240
+ resetWikiState(indexManager, wikiFs);
241
+ wikiFs.writePage("pages/topics/alpha/index.md", "---\ntitle: Alpha\nsummary: First topic\nupdated: 2026-05-01\n---\n\n# Alpha\n");
242
+ wikiFs.writePage("pages/topics/bad/index.md", "---\ntitle: Bad\nsummary: Broken topic\nupdated: 2026-05-02\n---\n\nUNPARSEABLE\n");
243
+ wikiFs.writePage("pages/topics/gamma/index.md", "---\ntitle: Gamma\nsummary: Third topic\nupdated: 2026-05-03\n---\n\n# Gamma\n");
244
+ const result = indexManager.reindexWikiPages();
245
+ const paths = indexManager.parseIndex().map((entry) => entry.path).sort();
246
+ assert.deepEqual(paths, [
247
+ "pages/topics/alpha/index.md",
248
+ "pages/topics/gamma/index.md",
249
+ ]);
250
+ assert.equal(result.diskPageCount, 3);
251
+ assert.equal(result.indexedPageCount, 2);
252
+ assert.ok(warnings.some((entry) => entry.obj.path === "pages/topics/bad/index.md"), "Expected a warning for the malformed page");
253
+ }
254
+ finally {
255
+ rmSync(sandbox, { recursive: true, force: true });
256
+ }
257
+ });
258
+ test("reindexWikiPages logs a summary with indexed and skipped counts", async (t) => {
259
+ const sandbox = makeSandbox();
260
+ try {
261
+ const { indexManager, wikiFs, infos } = await loadModulesWithMocks(t, sandbox);
262
+ resetWikiState(indexManager, wikiFs);
263
+ const unreadablePath = join(wikiFs.getWikiDir(), "pages", "topics", "blocked", "index.md");
264
+ wikiFs.writePage("pages/topics/alpha/index.md", "---\ntitle: Alpha\nsummary: First topic\nupdated: 2026-05-01\n---\n\n# Alpha\n");
265
+ wikiFs.writePage("pages/topics/blocked/index.md", "---\ntitle: Blocked\nsummary: Unreadable topic\nupdated: 2026-05-02\n---\n\n# Blocked\n");
266
+ wikiFs.writePage("pages/topics/gamma/index.md", "---\ntitle: Gamma\nsummary: Third topic\nupdated: 2026-05-03\n---\n\n# Gamma\n");
267
+ chmodSync(unreadablePath, 0o000);
268
+ try {
269
+ const result = indexManager.reindexWikiPages();
270
+ assert.equal(result.indexedPageCount, 2);
271
+ assert.ok(infos.some((entry) => /Reindexed 2 pages, skipped 1/.test(entry.msg)), "Expected a summary log with indexed and skipped counts");
272
+ }
273
+ finally {
274
+ chmodSync(unreadablePath, 0o644);
275
+ }
276
+ }
277
+ finally {
278
+ rmSync(sandbox, { recursive: true, force: true });
279
+ }
159
280
  });
160
281
  //# sourceMappingURL=index-manager.test.js.map