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.
- package/README.md +1 -1
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1780
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +6 -3
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +87 -85
- package/dist/memory/eot.test.js +71 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +43 -13
- package/dist/wiki/frontmatter.test.js +24 -0
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +1 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- 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
|
package/dist/wiki/frontmatter.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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");
|
package/dist/wiki/fs.test.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
22
|
-
indexManager.
|
|
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
|
-
|
|
110
|
-
indexManager.
|
|
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("
|
|
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");
|
package/dist/wiki/ingest.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
package/dist/wiki/log-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/wiki/migrate.js
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|