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 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 = 600_000;
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") {
@@ -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
@@ -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, listPages, writeRawSource, listSources, assertPagePath } from "../wiki/fs.js";
12
- import { searchIndex, addToIndex, removeFromIndex, parseIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
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 indexEntries = parseIndex();
1068
- const pages = listPages();
1069
- const sources = listSources();
1070
- const indexPaths = new Set(indexEntries.map((e) => e.path));
1071
- const orphans = pages.filter((p) => !indexPaths.has(p));
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("lint", `wiki_rebuild_index: rebuilt ${entries.length} entries from pages on disk`);
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(WIKI_DIR, "log.md");
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
- _Chronological record of wiki operations._
68
+ _Append-only record of wiki operations._
69
69
 
70
70
  `;
71
71
  /**
@@ -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.deepEqual(wiki.listPages(), ["pages/shared/runbooks/deploy.md"]);
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);