chapterhouse 0.3.15 → 0.3.17
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/agents.js +2 -0
- package/dist/copilot/agents.test.js +22 -0
- package/dist/copilot/skills.test.js +10 -0
- package/dist/copilot/system-message.js +2 -0
- package/dist/copilot/system-message.test.js +11 -0
- 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/dist/wiki/topic-structure.js +5 -1
- package/dist/wiki/topic-structure.test.js +1 -0
- package/package.json +1 -1
- package/skills/wiki-conventions/SKILL.md +145 -0
- 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/agents.js
CHANGED
|
@@ -222,6 +222,8 @@ You are an agent within Chapterhouse, a team-level AI assistant for engineering
|
|
|
222
222
|
### Shared Wiki
|
|
223
223
|
All agents share a wiki knowledge base for persistent memory. Use \`wiki_read\` and \`wiki_search\` to find existing knowledge, and \`wiki_update\` to save important findings.
|
|
224
224
|
|
|
225
|
+
Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wiki_update\`, \`remember\`, \`forget\`, \`wiki_ingest\`, \`wiki_lint\`, and \`wiki_rebuild_index\` as write-sensitive workflows. Before using them, read \`pages/index.md\`, scan the last 20-30 entries of \`pages/_meta/log.md\`, and run \`wiki_search\` for the topic when the wiki is large or the topic is ambiguous.
|
|
226
|
+
|
|
225
227
|
### Communication
|
|
226
228
|
- You receive tasks from @chapterhouse (the orchestrator) or directly from the user
|
|
227
229
|
- Your results are relayed back to the user by @chapterhouse
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { composeAgentSystemMessage, } from "./agents.js";
|
|
4
|
+
function makeAgent(slug) {
|
|
5
|
+
return {
|
|
6
|
+
slug,
|
|
7
|
+
name: slug,
|
|
8
|
+
description: `${slug} test agent`,
|
|
9
|
+
model: "claude-sonnet-4.6",
|
|
10
|
+
systemMessage: `You are ${slug}.`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
test("composeAgentSystemMessage steers wiki-capable agents to wiki-conventions", () => {
|
|
14
|
+
for (const slug of ["coder", "general-purpose"]) {
|
|
15
|
+
const message = composeAgentSystemMessage(makeAgent(slug));
|
|
16
|
+
assert.match(message, /invoke `wiki-conventions` before wiki writes/i);
|
|
17
|
+
assert.match(message, /wiki_update[\s\S]{0,80}remember[\s\S]{0,80}forget[\s\S]{0,80}wiki_ingest[\s\S]{0,80}wiki_lint[\s\S]{0,80}wiki_rebuild_index/i);
|
|
18
|
+
assert.match(message, /read `pages\/index\.md`/i);
|
|
19
|
+
assert.match(message, /scan the last 20-30 entries of `pages\/_meta\/log\.md`/i);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
//# sourceMappingURL=agents.test.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { listSkills } from "./skills.js";
|
|
4
|
+
test("listSkills includes bundled wiki-conventions skill metadata", () => {
|
|
5
|
+
const skill = listSkills().find((entry) => entry.slug === "wiki-conventions" && entry.source === "bundled");
|
|
6
|
+
assert.ok(skill);
|
|
7
|
+
assert.equal(skill.name, "wiki-conventions");
|
|
8
|
+
assert.match(skill.description, /creating, editing, linting, restructuring, or reviewing Chapterhouse wiki content/i);
|
|
9
|
+
});
|
|
10
|
+
//# sourceMappingURL=skills.test.js.map
|
|
@@ -122,6 +122,8 @@ You can delegate **multiple tasks simultaneously**. Different agents can work in
|
|
|
122
122
|
- With \`remember\`, pass \`entity\` for entity categories (and \`facet\` to target a sub-page). With \`wiki_update\`, give the full canonical path — bad paths are rejected with a suggested correction; just retry with the suggestion.
|
|
123
123
|
- If the structure ever looks wrong, call \`wiki_rebuild_index\` to regenerate the index from disk.
|
|
124
124
|
|
|
125
|
+
**Wiki writes and restructuring**: Before writing or restructuring wiki content, invoke the \`wiki-conventions\` skill. Treat \`wiki_update\`, \`remember\`, \`forget\`, \`wiki_ingest\`, \`wiki_lint\`, and \`wiki_rebuild_index\` as write-sensitive workflows. Before using them, read \`pages/index.md\`, scan the last 20-30 entries of \`pages/_meta/log.md\`, and run \`wiki_search\` for the topic when the wiki is large or the topic is ambiguous.
|
|
126
|
+
|
|
125
127
|
**Learning workflow**: When the user asks you to do something you don't have a skill for:
|
|
126
128
|
1. **Search skills.sh first**: Use the find-skills skill to search for existing community skills.
|
|
127
129
|
2. **Present what you found**: Tell the user the skill name, what it does, and its security status.
|
|
@@ -22,4 +22,15 @@ test("orchestrator prompt omits version banner when version is not provided", ()
|
|
|
22
22
|
const message = getOrchestratorSystemMessage();
|
|
23
23
|
assert.doesNotMatch(message, /chapterhouse v\d/);
|
|
24
24
|
});
|
|
25
|
+
test("orchestrator prompt requires wiki-conventions before write-sensitive wiki work", () => {
|
|
26
|
+
const message = getOrchestratorSystemMessage();
|
|
27
|
+
assert.match(message, /wiki-conventions[\s\S]{0,500}wiki_update[\s\S]{0,200}remember[\s\S]{0,200}forget[\s\S]{0,200}wiki_ingest[\s\S]{0,200}wiki_lint[\s\S]{0,200}wiki_rebuild_index/i);
|
|
28
|
+
assert.match(message, /before writing or restructuring wiki content/i);
|
|
29
|
+
});
|
|
30
|
+
test("orchestrator prompt describes the wiki orientation ritual", () => {
|
|
31
|
+
const message = getOrchestratorSystemMessage();
|
|
32
|
+
assert.match(message, /read `pages\/index\.md`/i);
|
|
33
|
+
assert.match(message, /scan the last 20-30 entries of `pages\/_meta\/log\.md`/i);
|
|
34
|
+
assert.match(message, /run `wiki_search` for the topic/i);
|
|
35
|
+
});
|
|
25
36
|
//# sourceMappingURL=system-message.test.js.map
|
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);
|