chapterhouse 0.3.15 → 0.3.16
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/dist/config.js +1 -1
- package/dist/copilot/tools.js +18 -29
- package/dist/copilot/tools.wiki.test.js +143 -0
- package/dist/wiki/frontmatter.js +148 -0
- package/dist/wiki/frontmatter.test.js +109 -0
- package/dist/wiki/fs.js +3 -3
- package/dist/wiki/fs.test.js +3 -2
- package/dist/wiki/index-manager.js +14 -32
- package/dist/wiki/index-manager.test.js +3 -3
- package/dist/wiki/lint.js +424 -0
- package/dist/wiki/lint.test.js +260 -0
- package/dist/wiki/log-manager.js +52 -9
- package/dist/wiki/log-manager.test.js +47 -0
- package/dist/wiki/taxonomy.js +73 -0
- package/dist/wiki/taxonomy.test.js +70 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BlIWCM11.js → index-BYuMgJ36.js} +61 -61
- package/web/dist/assets/index-BYuMgJ36.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BlIWCM11.js.map +0 -1
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import { API_TOKEN_PATH, ENV_PATH, ensureChapterhouseHome } from "./paths.js";
|
|
5
5
|
export const DISABLE_DOTENV_ENV_VAR = "CHAPTERHOUSE_DISABLE_DOTENV";
|
|
6
|
-
const DEFAULT_WORKER_TIMEOUT_MS =
|
|
6
|
+
const DEFAULT_WORKER_TIMEOUT_MS = 900_000;
|
|
7
7
|
const BOOLEAN_ENV_PATTERN = /^(true|false)$/;
|
|
8
8
|
function loadRuntimeEnv() {
|
|
9
9
|
if (process.env[DISABLE_DOTENV_ENV_VAR] === "1") {
|
package/dist/copilot/tools.js
CHANGED
|
@@ -8,9 +8,12 @@ import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
9
|
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
|
|
10
10
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
11
|
-
import { ensureWikiStructure, readPage, writePage, deletePage,
|
|
12
|
-
import { searchIndex, addToIndex, removeFromIndex,
|
|
11
|
+
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
12
|
+
import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
|
|
13
|
+
import { validateWikiFrontmatter } from "../wiki/frontmatter.js";
|
|
14
|
+
import { lintWiki, renderWikiLintReport } from "../wiki/lint.js";
|
|
13
15
|
import { appendLog } from "../wiki/log-manager.js";
|
|
16
|
+
import { loadTaxonomy } from "../wiki/taxonomy.js";
|
|
14
17
|
import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
|
|
15
18
|
import { withWikiWrite } from "../wiki/lock.js";
|
|
16
19
|
import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
@@ -960,20 +963,20 @@ export function createTools(deps) {
|
|
|
960
963
|
return withWikiWrite(async () => {
|
|
961
964
|
ensureWikiStructure();
|
|
962
965
|
assertPagePath(args.path);
|
|
966
|
+
const validation = validateWikiFrontmatter(args.content, {
|
|
967
|
+
allowedTags: loadTaxonomy(),
|
|
968
|
+
});
|
|
969
|
+
if (!validation.valid) {
|
|
970
|
+
throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
|
|
971
|
+
}
|
|
963
972
|
writePage(args.path, args.content);
|
|
964
|
-
// Rebuild from disk so the index summary/tags/updated reflect the actual page
|
|
965
|
-
// but prefer caller-supplied title/summary/section as overrides.
|
|
973
|
+
// Rebuild from disk so the index summary/tags/updated reflect the actual page.
|
|
966
974
|
const today = new Date().toISOString().slice(0, 10);
|
|
967
975
|
const rebuilt = buildIndexEntryForPage(args.path, {
|
|
968
|
-
title: args.title,
|
|
969
|
-
summary: indexSafe(args.summary).slice(0, 160),
|
|
970
976
|
section: args.section || "Knowledge",
|
|
971
977
|
updated: today,
|
|
972
978
|
});
|
|
973
979
|
if (rebuilt) {
|
|
974
|
-
// Overrides win even if the page frontmatter says otherwise.
|
|
975
|
-
rebuilt.title = args.title;
|
|
976
|
-
rebuilt.summary = indexSafe(args.summary).slice(0, 160);
|
|
977
980
|
rebuilt.section = args.section || "Knowledge";
|
|
978
981
|
addToIndex(rebuilt);
|
|
979
982
|
}
|
|
@@ -1064,25 +1067,11 @@ export function createTools(deps) {
|
|
|
1064
1067
|
parameters: z.object({}),
|
|
1065
1068
|
handler: async () => {
|
|
1066
1069
|
ensureWikiStructure();
|
|
1067
|
-
const
|
|
1068
|
-
const
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
const missing = indexEntries.filter((e) => !readPage(e.path));
|
|
1073
|
-
const report = [`Wiki health report (${pages.length} pages, ${sources.length} sources):`];
|
|
1074
|
-
if (orphans.length > 0) {
|
|
1075
|
-
report.push(`\n**Orphan pages** (not in index):\n${orphans.map((p) => `- ${p}`).join("\n")}`);
|
|
1076
|
-
}
|
|
1077
|
-
if (missing.length > 0) {
|
|
1078
|
-
report.push(`\n**Missing pages** (in index but not on disk):\n${missing.map((e) => `- ${e.path}: ${e.title}`).join("\n")}`);
|
|
1079
|
-
}
|
|
1080
|
-
if (orphans.length === 0 && missing.length === 0) {
|
|
1081
|
-
report.push("\n✅ No issues found. Index and pages are in sync.");
|
|
1082
|
-
}
|
|
1083
|
-
report.push(`\n**Suggestions**: Look for pages that should link to each other, topics mentioned but lacking their own page, and stale content that needs updating.`);
|
|
1084
|
-
appendLog("lint", `${orphans.length} orphans, ${missing.length} missing`);
|
|
1085
|
-
return report.join("\n");
|
|
1070
|
+
const report = lintWiki();
|
|
1071
|
+
const orphanCount = report.issues.filter((issue) => issue.rule === "orphan-page").length;
|
|
1072
|
+
const missingCount = report.issues.filter((issue) => issue.rule === "missing-page").length;
|
|
1073
|
+
appendLog("lint", `${orphanCount} orphans, ${missingCount} missing`);
|
|
1074
|
+
return renderWikiLintReport(report);
|
|
1086
1075
|
},
|
|
1087
1076
|
}),
|
|
1088
1077
|
defineTool("wiki_rebuild_index", {
|
|
@@ -1094,7 +1083,7 @@ export function createTools(deps) {
|
|
|
1094
1083
|
return withWikiWrite(async () => {
|
|
1095
1084
|
const { rebuildIndexFromPages } = await import("../wiki/index-manager.js");
|
|
1096
1085
|
const entries = rebuildIndexFromPages();
|
|
1097
|
-
appendLog("
|
|
1086
|
+
appendLog("rebuild-index", `wiki_rebuild_index: rebuilt ${entries.length} entries from pages on disk`);
|
|
1098
1087
|
return `Rebuilt index with ${entries.length} entries.`;
|
|
1099
1088
|
});
|
|
1100
1089
|
},
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
async function loadToolsModule() {
|
|
7
|
+
return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
8
|
+
}
|
|
9
|
+
async function readWikiArtifacts() {
|
|
10
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
11
|
+
const wikiFs = await import(new URL(`../wiki/fs.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
+
return wikiFs;
|
|
13
|
+
}
|
|
14
|
+
test.beforeEach(() => {
|
|
15
|
+
process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-wiki-"));
|
|
16
|
+
process.env.CHAPTERHOUSE_AGENT_NAME = "tools-test-agent";
|
|
17
|
+
});
|
|
18
|
+
test.afterEach(async () => {
|
|
19
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
20
|
+
if (home) {
|
|
21
|
+
const dbModule = await import("../store/db.js");
|
|
22
|
+
dbModule.closeDb();
|
|
23
|
+
rmSync(home, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
test("wiki_update rejects content without required frontmatter", async () => {
|
|
27
|
+
const toolsModule = await loadToolsModule();
|
|
28
|
+
const tools = toolsModule.createTools({
|
|
29
|
+
client: { async listModels() { return []; } },
|
|
30
|
+
onAgentTaskComplete: () => { },
|
|
31
|
+
});
|
|
32
|
+
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
33
|
+
assert.ok(tool);
|
|
34
|
+
await assert.rejects(tool.handler({
|
|
35
|
+
path: "pages/shared/chapterhouse.md",
|
|
36
|
+
title: "Chapterhouse",
|
|
37
|
+
summary: "Runtime notes",
|
|
38
|
+
content: "# Chapterhouse\n\nRuntime notes.\n",
|
|
39
|
+
}), /Wiki page frontmatter violates the required shape/i);
|
|
40
|
+
});
|
|
41
|
+
test("wiki_update rejects malformed summaries and unknown tags", async () => {
|
|
42
|
+
const toolsModule = await loadToolsModule();
|
|
43
|
+
const tools = toolsModule.createTools({
|
|
44
|
+
client: { async listModels() { return []; } },
|
|
45
|
+
onAgentTaskComplete: () => { },
|
|
46
|
+
});
|
|
47
|
+
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
48
|
+
assert.ok(tool);
|
|
49
|
+
await assert.rejects(tool.handler({
|
|
50
|
+
path: "pages/shared/chapterhouse.md",
|
|
51
|
+
title: "Chapterhouse",
|
|
52
|
+
summary: "Runtime notes",
|
|
53
|
+
content: `---
|
|
54
|
+
title: Chapterhouse
|
|
55
|
+
summary: **Runtime notes**
|
|
56
|
+
tags: [engineering, made-up-tag]
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# Chapterhouse
|
|
60
|
+
|
|
61
|
+
Runtime notes.
|
|
62
|
+
`,
|
|
63
|
+
}), /Add it to `pages\/_meta\/taxonomy\.md` first\./);
|
|
64
|
+
});
|
|
65
|
+
test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
|
|
66
|
+
const toolsModule = await loadToolsModule();
|
|
67
|
+
const tools = toolsModule.createTools({
|
|
68
|
+
client: { async listModels() { return []; } },
|
|
69
|
+
onAgentTaskComplete: () => { },
|
|
70
|
+
});
|
|
71
|
+
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
72
|
+
assert.ok(tool);
|
|
73
|
+
const result = await tool.handler({
|
|
74
|
+
path: "pages/shared/chapterhouse.md",
|
|
75
|
+
title: "Chapterhouse",
|
|
76
|
+
summary: "Runtime notes",
|
|
77
|
+
content: `---
|
|
78
|
+
title: Chapterhouse
|
|
79
|
+
summary: Runtime notes
|
|
80
|
+
tags: [engineering]
|
|
81
|
+
autostub: true
|
|
82
|
+
confidence: low
|
|
83
|
+
contested: true
|
|
84
|
+
contradictions: [pages/shared/other.md]
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
# Chapterhouse
|
|
88
|
+
|
|
89
|
+
Runtime notes.
|
|
90
|
+
`,
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
|
|
93
|
+
const wikiFs = await readWikiArtifacts();
|
|
94
|
+
assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /summary: Runtime notes/);
|
|
95
|
+
assert.match(wikiFs.readIndexFile(), /\[Chapterhouse\]\(pages\/shared\/chapterhouse\.md\) — Runtime notes/);
|
|
96
|
+
});
|
|
97
|
+
test("wiki tools append audit entries to pages/_meta/log.md", async () => {
|
|
98
|
+
const toolsModule = await loadToolsModule();
|
|
99
|
+
const tools = toolsModule.createTools({
|
|
100
|
+
client: { async listModels() { return []; } },
|
|
101
|
+
onAgentTaskComplete: () => { },
|
|
102
|
+
});
|
|
103
|
+
const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
|
|
104
|
+
const wikiIngest = tools.find((entry) => entry.name === "wiki_ingest");
|
|
105
|
+
const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
|
|
106
|
+
const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
|
|
107
|
+
const forget = tools.find((entry) => entry.name === "forget");
|
|
108
|
+
assert.ok(wikiUpdate && wikiIngest && wikiLint && wikiRebuildIndex && forget);
|
|
109
|
+
await wikiUpdate.handler({
|
|
110
|
+
path: "pages/shared/chapterhouse.md",
|
|
111
|
+
title: "Chapterhouse",
|
|
112
|
+
summary: "Runtime notes",
|
|
113
|
+
content: `---
|
|
114
|
+
title: Chapterhouse
|
|
115
|
+
summary: Runtime notes
|
|
116
|
+
updated: 2026-05-12
|
|
117
|
+
tags: [engineering]
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
# Chapterhouse
|
|
121
|
+
|
|
122
|
+
## Overview
|
|
123
|
+
|
|
124
|
+
Runtime notes with enough content to avoid incidental lint noise in the audit-log test.
|
|
125
|
+
`,
|
|
126
|
+
});
|
|
127
|
+
await wikiIngest.handler({
|
|
128
|
+
type: "text",
|
|
129
|
+
source: "Source content for the wiki ingest audit log test.",
|
|
130
|
+
name: "audit-log-source",
|
|
131
|
+
});
|
|
132
|
+
await wikiLint.handler({});
|
|
133
|
+
await wikiRebuildIndex.handler({});
|
|
134
|
+
await forget.handler({ page_path: "pages/shared/chapterhouse.md" });
|
|
135
|
+
const wikiFs = await readWikiArtifacts();
|
|
136
|
+
const log = wikiFs.readLogFile();
|
|
137
|
+
assert.match(log, /update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| tools-test-agent/);
|
|
138
|
+
assert.match(log, /ingest \| Ingested text: audit-log-source \(\d+ chars\) \| tools-test-agent/);
|
|
139
|
+
assert.match(log, /lint \| .* \| tools-test-agent/);
|
|
140
|
+
assert.match(log, /rebuild-index \| wiki_rebuild_index: rebuilt \d+ entries from pages on disk \| tools-test-agent/);
|
|
141
|
+
assert.match(log, /delete \| forget: deleted page pages\/shared\/chapterhouse\.md \| tools-test-agent/);
|
|
142
|
+
});
|
|
143
|
+
//# sourceMappingURL=tools.wiki.test.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
|
|
2
|
+
const SUMMARY_MARKDOWN_RE = /(\*\*|__|[_*`~]|^\s*#+\s|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|^\s*>)/m;
|
|
3
|
+
const FRONTMATTER_TEMPLATE = `---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---`;
|
|
4
|
+
export function parseWikiFrontmatter(content) {
|
|
5
|
+
const match = content.match(FRONTMATTER_RE);
|
|
6
|
+
if (!match) {
|
|
7
|
+
return {
|
|
8
|
+
parsed: { metadata: {} },
|
|
9
|
+
body: content,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const parsed = { metadata: {} };
|
|
13
|
+
for (const line of match[1].split("\n")) {
|
|
14
|
+
const idx = line.indexOf(":");
|
|
15
|
+
if (idx <= 0)
|
|
16
|
+
continue;
|
|
17
|
+
const key = line.slice(0, idx).trim();
|
|
18
|
+
const rawValue = line.slice(idx + 1).trim();
|
|
19
|
+
const value = parseValue(rawValue);
|
|
20
|
+
switch (key) {
|
|
21
|
+
case "title":
|
|
22
|
+
case "summary":
|
|
23
|
+
case "updated":
|
|
24
|
+
if (typeof value === "string")
|
|
25
|
+
parsed[key] = value;
|
|
26
|
+
else
|
|
27
|
+
parsed.metadata[key] = value;
|
|
28
|
+
break;
|
|
29
|
+
case "tags":
|
|
30
|
+
case "contradictions":
|
|
31
|
+
case "related":
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
parsed[key] = value;
|
|
34
|
+
else
|
|
35
|
+
parsed.metadata[key] = value;
|
|
36
|
+
break;
|
|
37
|
+
case "autostub":
|
|
38
|
+
case "contested":
|
|
39
|
+
if (typeof value === "boolean")
|
|
40
|
+
parsed[key] = value;
|
|
41
|
+
else
|
|
42
|
+
parsed.metadata[key] = value;
|
|
43
|
+
break;
|
|
44
|
+
case "confidence":
|
|
45
|
+
if (value === "high" || value === "medium" || value === "low") {
|
|
46
|
+
parsed.confidence = value;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
parsed.metadata[key] = value;
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
parsed.metadata[key] = value;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
parsed,
|
|
59
|
+
body: content.slice(match[0].length),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function hasWikiFrontmatter(content) {
|
|
63
|
+
return FRONTMATTER_RE.test(content);
|
|
64
|
+
}
|
|
65
|
+
export function validateWikiFrontmatter(content, options = {}) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const { parsed } = parseWikiFrontmatter(content);
|
|
68
|
+
if (!hasWikiFrontmatter(content)) {
|
|
69
|
+
errors.push({
|
|
70
|
+
rule: "missing-frontmatter",
|
|
71
|
+
message: "Wiki page frontmatter violates the required shape: missing YAML frontmatter. Use:\n" + FRONTMATTER_TEMPLATE,
|
|
72
|
+
});
|
|
73
|
+
return { valid: false, errors };
|
|
74
|
+
}
|
|
75
|
+
if (!parsed.title?.trim()) {
|
|
76
|
+
errors.push({
|
|
77
|
+
rule: "missing-title",
|
|
78
|
+
field: "title",
|
|
79
|
+
message: formatFrontmatterMessage("missing 'title'"),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!parsed.summary?.trim()) {
|
|
83
|
+
errors.push({
|
|
84
|
+
rule: "missing-summary",
|
|
85
|
+
field: "summary",
|
|
86
|
+
message: formatFrontmatterMessage("missing 'summary'"),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const summary = parsed.summary.trim();
|
|
91
|
+
if (summary.length > 200 ||
|
|
92
|
+
summary.includes("\n") ||
|
|
93
|
+
summary.includes("\r") ||
|
|
94
|
+
SUMMARY_MARKDOWN_RE.test(summary)) {
|
|
95
|
+
errors.push({
|
|
96
|
+
rule: "invalid-summary",
|
|
97
|
+
field: "summary",
|
|
98
|
+
message: formatFrontmatterMessage("invalid 'summary' (use plain text, one line, max 200 chars)"),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const field of ["updated", "tags", "autostub", "confidence", "contested", "contradictions", "related"]) {
|
|
103
|
+
if (field in parsed.metadata) {
|
|
104
|
+
errors.push({
|
|
105
|
+
rule: "invalid-field-type",
|
|
106
|
+
field,
|
|
107
|
+
message: formatFrontmatterMessage(`invalid '${field}' type`),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (options.allowedTags && parsed.tags) {
|
|
112
|
+
const allowed = new Set(options.allowedTags.map((tag) => tag.toLowerCase()));
|
|
113
|
+
for (const tag of parsed.tags) {
|
|
114
|
+
if (!allowed.has(tag.toLowerCase())) {
|
|
115
|
+
errors.push({
|
|
116
|
+
rule: "unknown-tag",
|
|
117
|
+
field: "tags",
|
|
118
|
+
message: formatFrontmatterMessage(`unknown tag '${tag}'. Add it to \`pages/_meta/taxonomy.md\` first.`),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
valid: errors.length === 0,
|
|
125
|
+
errors,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function formatFrontmatterMessage(reason) {
|
|
129
|
+
return `Wiki page frontmatter violates the required shape: ${reason}. Use:\n${FRONTMATTER_TEMPLATE}`;
|
|
130
|
+
}
|
|
131
|
+
function parseValue(rawValue) {
|
|
132
|
+
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
|
|
133
|
+
return rawValue
|
|
134
|
+
.slice(1, -1)
|
|
135
|
+
.split(",")
|
|
136
|
+
.map((item) => stripQuotes(item.trim()))
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
if (rawValue === "true")
|
|
140
|
+
return true;
|
|
141
|
+
if (rawValue === "false")
|
|
142
|
+
return false;
|
|
143
|
+
return stripQuotes(rawValue);
|
|
144
|
+
}
|
|
145
|
+
function stripQuotes(value) {
|
|
146
|
+
return value.replace(/^['"]|['"]$/g, "");
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=frontmatter.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
async function loadFrontmatterModule() {
|
|
4
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
5
|
+
return await import(new URL(`./frontmatter.js?case=${nonce}`, import.meta.url).href);
|
|
6
|
+
}
|
|
7
|
+
test("parseWikiFrontmatter parses typed known fields and preserves unknown metadata", async () => {
|
|
8
|
+
const { parseWikiFrontmatter } = await loadFrontmatterModule();
|
|
9
|
+
const result = parseWikiFrontmatter(`---
|
|
10
|
+
title: Chapterhouse
|
|
11
|
+
summary: Team orchestrator runtime
|
|
12
|
+
updated: 2026-05-12
|
|
13
|
+
tags: [engineering, orchestration]
|
|
14
|
+
autostub: true
|
|
15
|
+
confidence: low
|
|
16
|
+
contested: true
|
|
17
|
+
contradictions: [pages/projects/chapterhouse/decisions.md]
|
|
18
|
+
related: [pages/projects/chapterhouse/index.md]
|
|
19
|
+
owner: brian
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Chapterhouse
|
|
23
|
+
|
|
24
|
+
Runtime notes.
|
|
25
|
+
`);
|
|
26
|
+
assert.deepEqual(result, {
|
|
27
|
+
parsed: {
|
|
28
|
+
title: "Chapterhouse",
|
|
29
|
+
summary: "Team orchestrator runtime",
|
|
30
|
+
updated: "2026-05-12",
|
|
31
|
+
tags: ["engineering", "orchestration"],
|
|
32
|
+
autostub: true,
|
|
33
|
+
confidence: "low",
|
|
34
|
+
contested: true,
|
|
35
|
+
contradictions: ["pages/projects/chapterhouse/decisions.md"],
|
|
36
|
+
related: ["pages/projects/chapterhouse/index.md"],
|
|
37
|
+
metadata: {
|
|
38
|
+
owner: "brian",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
body: "# Chapterhouse\n\nRuntime notes.\n",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
test("parseWikiFrontmatter tolerates legacy pages without frontmatter", async () => {
|
|
45
|
+
const { parseWikiFrontmatter } = await loadFrontmatterModule();
|
|
46
|
+
const result = parseWikiFrontmatter("# Legacy Page\n\nStill readable.\n");
|
|
47
|
+
assert.deepEqual(result, {
|
|
48
|
+
parsed: {
|
|
49
|
+
metadata: {},
|
|
50
|
+
},
|
|
51
|
+
body: "# Legacy Page\n\nStill readable.\n",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
test("validateWikiFrontmatter requires authored pages to include title and summary", async () => {
|
|
55
|
+
const { validateWikiFrontmatter } = await loadFrontmatterModule();
|
|
56
|
+
const result = validateWikiFrontmatter(`---
|
|
57
|
+
title: Chapterhouse
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
# Chapterhouse
|
|
61
|
+
`);
|
|
62
|
+
assert.equal(result.valid, false);
|
|
63
|
+
assert.deepEqual(result.errors.map((error) => error.rule), ["missing-summary"]);
|
|
64
|
+
assert.match(result.errors[0]?.message ?? "", /missing 'summary'/i);
|
|
65
|
+
});
|
|
66
|
+
test("validateWikiFrontmatter rejects malformed summaries and invalid optional field types", async () => {
|
|
67
|
+
const { validateWikiFrontmatter } = await loadFrontmatterModule();
|
|
68
|
+
const result = validateWikiFrontmatter(`---
|
|
69
|
+
title: Chapterhouse
|
|
70
|
+
summary: **Bold summary**
|
|
71
|
+
autostub: maybe
|
|
72
|
+
confidence: unsure
|
|
73
|
+
contested: perhaps
|
|
74
|
+
contradictions: nope
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Chapterhouse
|
|
78
|
+
`);
|
|
79
|
+
assert.equal(result.valid, false);
|
|
80
|
+
assert.deepEqual(result.errors.map((error) => error.rule), [
|
|
81
|
+
"invalid-summary",
|
|
82
|
+
"invalid-field-type",
|
|
83
|
+
"invalid-field-type",
|
|
84
|
+
"invalid-field-type",
|
|
85
|
+
"invalid-field-type",
|
|
86
|
+
]);
|
|
87
|
+
assert.deepEqual(result.errors.map((error) => error.field), [
|
|
88
|
+
"summary",
|
|
89
|
+
"autostub",
|
|
90
|
+
"confidence",
|
|
91
|
+
"contested",
|
|
92
|
+
"contradictions",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
test("validateWikiFrontmatter rejects unknown tags after taxonomy loading", async () => {
|
|
96
|
+
const { validateWikiFrontmatter } = await loadFrontmatterModule();
|
|
97
|
+
const result = validateWikiFrontmatter(`---
|
|
98
|
+
title: Deploy
|
|
99
|
+
summary: Runbook for production deployments
|
|
100
|
+
tags: [engineering, made-up-tag]
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
# Deploy
|
|
104
|
+
`, { allowedTags: ["engineering", "release"] });
|
|
105
|
+
assert.equal(result.valid, false);
|
|
106
|
+
assert.deepEqual(result.errors.map((error) => error.rule), ["unknown-tag"]);
|
|
107
|
+
assert.match(result.errors[0]?.message ?? "", /Add it to `pages\/_meta\/taxonomy\.md` first\./);
|
|
108
|
+
});
|
|
109
|
+
//# sourceMappingURL=frontmatter.test.js.map
|
package/dist/wiki/fs.js
CHANGED
|
@@ -7,7 +7,7 @@ import { WIKI_DIR, WIKI_PAGES_DIR, WIKI_SOURCES_DIR } from "../paths.js";
|
|
|
7
7
|
import { normalizeWikiPath } from "./path-utils.js";
|
|
8
8
|
import { topicPathError } from "./topic-structure.js";
|
|
9
9
|
const INDEX_PATH = join(WIKI_DIR, "index.md");
|
|
10
|
-
const LOG_PATH = join(
|
|
10
|
+
const LOG_PATH = join(WIKI_PAGES_DIR, "_meta", "log.md");
|
|
11
11
|
/**
|
|
12
12
|
* Write a file atomically: write to a temp file in the same directory, fsync,
|
|
13
13
|
* then rename over the destination. Prevents partial writes on crash and
|
|
@@ -63,9 +63,9 @@ Last updated: ${new Date().toISOString().slice(0, 10)}
|
|
|
63
63
|
_(No pages yet.)_
|
|
64
64
|
`;
|
|
65
65
|
}
|
|
66
|
-
const INITIAL_LOG = `# Wiki Log
|
|
66
|
+
const INITIAL_LOG = `# Wiki Action Log
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
_Append-only record of wiki operations._
|
|
69
69
|
|
|
70
70
|
`;
|
|
71
71
|
/**
|
package/dist/wiki/fs.test.js
CHANGED
|
@@ -20,11 +20,12 @@ test("wiki fs creates the wiki structure and supports page CRUD", async () => {
|
|
|
20
20
|
assert.equal(wiki.ensureWikiStructure(), true);
|
|
21
21
|
assert.equal(wiki.ensureWikiStructure(), false);
|
|
22
22
|
assert.equal(existsSync(join(wikiDir, "index.md")), true);
|
|
23
|
-
assert.equal(existsSync(join(wikiDir, "log.md")), true);
|
|
23
|
+
assert.equal(existsSync(join(wikiDir, "pages", "_meta", "log.md")), true);
|
|
24
24
|
wiki.writePage("pages/shared/runbooks/deploy.md", "# Deploy\n");
|
|
25
25
|
assert.equal(wiki.pageExists("pages/shared/runbooks/deploy.md"), true);
|
|
26
26
|
assert.equal(wiki.readPage("pages/shared/runbooks/deploy.md"), "# Deploy\n");
|
|
27
|
-
assert.
|
|
27
|
+
assert.equal(wiki.listPages().includes("pages/shared/runbooks/deploy.md"), true);
|
|
28
|
+
assert.match(wiki.readLogFile(), /^# Wiki Action Log/m);
|
|
28
29
|
assert.equal(wiki.deletePage("pages/shared/runbooks/deploy.md"), true);
|
|
29
30
|
assert.equal(wiki.deletePage("pages/shared/runbooks/deploy.md"), false);
|
|
30
31
|
assert.equal(wiki.readPage("pages/shared/runbooks/deploy.md"), undefined);
|
|
@@ -5,9 +5,11 @@ import { existsSync, statSync } from "fs";
|
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { WIKI_DIR } from "../paths.js";
|
|
7
7
|
import { readIndexFile, writeIndexFile, listPages, readPage } from "./fs.js";
|
|
8
|
+
import { parseWikiFrontmatter } from "./frontmatter.js";
|
|
8
9
|
import { normalizeWikiPath } from "./path-utils.js";
|
|
9
10
|
import { entityCategories, FLAT_CATEGORIES } from "./topic-structure.js";
|
|
10
11
|
const INDEX_PATH = join(WIKI_DIR, "index.md");
|
|
12
|
+
const ACTION_LOG_PAGE_RE = /^pages\/_meta\/log(?:-\d{4})?\.md$/;
|
|
11
13
|
// mtime-based cache so per-message context injection doesn't re-parse on every turn.
|
|
12
14
|
let cache;
|
|
13
15
|
function invalidateCache() {
|
|
@@ -73,7 +75,7 @@ export function parseIndex() {
|
|
|
73
75
|
}
|
|
74
76
|
// Self-heal: if index is empty/corrupted but pages exist on disk, rebuild from disk.
|
|
75
77
|
if (entries.length === 0) {
|
|
76
|
-
const pages = listPages();
|
|
78
|
+
const pages = listPages().filter((path) => !isActionLogPage(path));
|
|
77
79
|
if (pages.length > 0) {
|
|
78
80
|
const rebuilt = rebuildIndexFromPages();
|
|
79
81
|
cache = { mtimeMs, size, entries: rebuilt };
|
|
@@ -83,43 +85,20 @@ export function parseIndex() {
|
|
|
83
85
|
cache = { mtimeMs, size, entries };
|
|
84
86
|
return entries;
|
|
85
87
|
}
|
|
86
|
-
/** Parse YAML frontmatter (very simple — supports key: value and key: [a, b]). */
|
|
87
|
-
function parseFrontmatter(content) {
|
|
88
|
-
const m = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
89
|
-
if (!m)
|
|
90
|
-
return {};
|
|
91
|
-
const out = {};
|
|
92
|
-
for (const line of m[1].split("\n")) {
|
|
93
|
-
const idx = line.indexOf(":");
|
|
94
|
-
if (idx <= 0)
|
|
95
|
-
continue;
|
|
96
|
-
const key = line.slice(0, idx).trim();
|
|
97
|
-
let value = line.slice(idx + 1).trim();
|
|
98
|
-
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
|
|
99
|
-
value = value.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
|
|
100
|
-
}
|
|
101
|
-
else if (typeof value === "string") {
|
|
102
|
-
value = value.replace(/^['"]|['"]$/g, "");
|
|
103
|
-
}
|
|
104
|
-
out[key] = value;
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
88
|
/** Build (or refresh) an IndexEntry by reading the page from disk. */
|
|
109
89
|
export function buildIndexEntryForPage(path, fallback) {
|
|
110
90
|
const normalizedPath = normalizeWikiPath(path);
|
|
111
91
|
const content = readPage(normalizedPath);
|
|
112
92
|
if (!content)
|
|
113
93
|
return undefined;
|
|
114
|
-
const fm =
|
|
115
|
-
const title =
|
|
116
|
-
const tags =
|
|
117
|
-
const updated =
|
|
118
|
-
//
|
|
119
|
-
// non-heading content line
|
|
120
|
-
let summary = fallback?.summary?.trim() || "";
|
|
94
|
+
const { parsed: fm, body } = parseWikiFrontmatter(content);
|
|
95
|
+
const title = fm.title || fallback?.title || basenameTitle(normalizedPath);
|
|
96
|
+
const tags = fm.tags ?? fallback?.tags ?? [];
|
|
97
|
+
const updated = fm.updated || fallback?.updated;
|
|
98
|
+
// Compliant pages treat frontmatter summary as canonical. Legacy pages fall back
|
|
99
|
+
// to the first non-frontmatter, non-heading content line.
|
|
100
|
+
let summary = fm.summary?.trim() || fallback?.summary?.trim() || "";
|
|
121
101
|
if (!summary) {
|
|
122
|
-
const body = content.replace(/^---[\s\S]*?---\s*/, "");
|
|
123
102
|
for (const raw of body.split("\n")) {
|
|
124
103
|
const line = raw.trim();
|
|
125
104
|
if (!line || line.startsWith("#") || line.startsWith("<!--"))
|
|
@@ -149,7 +128,7 @@ function basenameTitle(path) {
|
|
|
149
128
|
}
|
|
150
129
|
/** Rebuild every index entry from on-disk pages. Preserves section if known. */
|
|
151
130
|
export function rebuildIndexFromPages() {
|
|
152
|
-
const pages = listPages();
|
|
131
|
+
const pages = listPages().filter((path) => !isActionLogPage(path));
|
|
153
132
|
const previous = new Map();
|
|
154
133
|
// Try to keep section assignments by re-parsing the (possibly-corrupted) index without recursion.
|
|
155
134
|
try {
|
|
@@ -288,6 +267,9 @@ function writeIndexInternal(entries) {
|
|
|
288
267
|
}
|
|
289
268
|
writeIndexFile(lines.join("\n"));
|
|
290
269
|
}
|
|
270
|
+
function isActionLogPage(path) {
|
|
271
|
+
return ACTION_LOG_PAGE_RE.test(path);
|
|
272
|
+
}
|
|
291
273
|
/** Add or update an entry in the index. Upserts by path. */
|
|
292
274
|
export function addToIndex(entry) {
|
|
293
275
|
const normalizedEntry = { ...entry, path: normalizeWikiPath(entry.path) };
|
|
@@ -43,14 +43,14 @@ test("parseIndex reads sections, summaries, tags, and updated dates", async () =
|
|
|
43
43
|
},
|
|
44
44
|
]);
|
|
45
45
|
});
|
|
46
|
-
test("buildIndexEntryForPage
|
|
46
|
+
test("buildIndexEntryForPage treats frontmatter summary as the canonical index summary", async () => {
|
|
47
47
|
const { indexManager, wikiFs } = await loadModules();
|
|
48
|
-
wikiFs.writePage("pages/shared/runbooks/deploy.md", `---\ntitle: Deploy Runbook\ntags: [ops, release]\nupdated: 2026-05-04\n---\n\n# Deploy\n\n${"Deploy carefully ".repeat(20)}\n`);
|
|
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
49
|
const entry = indexManager.buildIndexEntryForPage("pages/shared/runbooks/deploy.md");
|
|
50
50
|
assert.deepEqual(entry, {
|
|
51
51
|
path: "pages/shared/runbooks/deploy.md",
|
|
52
52
|
title: "Deploy Runbook",
|
|
53
|
-
summary:
|
|
53
|
+
summary: "Production deployment checklist",
|
|
54
54
|
section: "Knowledge",
|
|
55
55
|
tags: ["ops", "release"],
|
|
56
56
|
updated: "2026-05-04",
|