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 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") {
@@ -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);
@@ -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 = parseFrontmatter(content);
115
- const title = (typeof fm.title === "string" && fm.title) || fallback?.title || basenameTitle(normalizedPath);
116
- const tags = Array.isArray(fm.tags) ? fm.tags : (fallback?.tags ?? []);
117
- const updated = (typeof fm.updated === "string" && fm.updated) || fallback?.updated;
118
- // Summary heuristic: existing summary if provided, else first non-frontmatter
119
- // non-heading content line trimmed to 160 chars.
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 derives title, metadata, and a trimmed summary from page content", async () => {
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: `${("Deploy carefully ".repeat(20)).trim().slice(0, 157)}…`,
53
+ summary: "Production deployment checklist",
54
54
  section: "Knowledge",
55
55
  tags: ["ops", "release"],
56
56
  updated: "2026-05-04",