chapterhouse 0.3.14 → 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.
@@ -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
@@ -5,8 +5,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlink
5
5
  import { join, dirname, relative, resolve, sep } from "path";
6
6
  import { WIKI_DIR, WIKI_PAGES_DIR, WIKI_SOURCES_DIR } from "../paths.js";
7
7
  import { normalizeWikiPath } from "./path-utils.js";
8
+ import { topicPathError } from "./topic-structure.js";
8
9
  const INDEX_PATH = join(WIKI_DIR, "index.md");
9
- const LOG_PATH = join(WIKI_DIR, "log.md");
10
+ const LOG_PATH = join(WIKI_PAGES_DIR, "_meta", "log.md");
10
11
  /**
11
12
  * Write a file atomically: write to a temp file in the same directory, fsync,
12
13
  * then rename over the destination. Prevents partial writes on crash and
@@ -43,6 +44,10 @@ export function assertPagePath(relativePath) {
43
44
  if (!normalizedPath.endsWith(".md")) {
44
45
  throw new Error(`Wiki page paths must end in .md: ${relativePath}`);
45
46
  }
47
+ const structureError = topicPathError(normalizedPath);
48
+ if (structureError) {
49
+ throw new Error(`Wiki path violates the topic structure: ${structureError}`);
50
+ }
46
51
  // resolvePath also enforces the wiki-root containment check.
47
52
  resolvePath(normalizedPath);
48
53
  }
@@ -58,9 +63,9 @@ Last updated: ${new Date().toISOString().slice(0, 10)}
58
63
  _(No pages yet.)_
59
64
  `;
60
65
  }
61
- const INITIAL_LOG = `# Wiki Log
66
+ const INITIAL_LOG = `# Wiki Action Log
62
67
 
63
- _Chronological record of wiki operations._
68
+ _Append-only record of wiki operations._
64
69
 
65
70
  `;
66
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,8 +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";
10
+ import { entityCategories, FLAT_CATEGORIES } from "./topic-structure.js";
9
11
  const INDEX_PATH = join(WIKI_DIR, "index.md");
12
+ const ACTION_LOG_PAGE_RE = /^pages\/_meta\/log(?:-\d{4})?\.md$/;
10
13
  // mtime-based cache so per-message context injection doesn't re-parse on every turn.
11
14
  let cache;
12
15
  function invalidateCache() {
@@ -39,8 +42,9 @@ export function parseIndex() {
39
42
  currentSection = sectionMatch[1].trim();
40
43
  continue;
41
44
  }
42
- // Entry lines: - [Title](path) Summary | tags: t1, t2 | updated: YYYY-MM-DD
43
- const entryMatch = line.match(/^-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/);
45
+ // Entry lines (possibly indented sub-bullets):
46
+ // - [Title](path) — Summary | tags: t1, t2 | updated: YYYY-MM-DD
47
+ const entryMatch = line.match(/^\s*-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/);
44
48
  if (entryMatch) {
45
49
  const rawSummary = entryMatch[3].trim();
46
50
  // Parse optional | tags: ... | updated: ... suffixes
@@ -71,7 +75,7 @@ export function parseIndex() {
71
75
  }
72
76
  // Self-heal: if index is empty/corrupted but pages exist on disk, rebuild from disk.
73
77
  if (entries.length === 0) {
74
- const pages = listPages();
78
+ const pages = listPages().filter((path) => !isActionLogPage(path));
75
79
  if (pages.length > 0) {
76
80
  const rebuilt = rebuildIndexFromPages();
77
81
  cache = { mtimeMs, size, entries: rebuilt };
@@ -81,48 +85,27 @@ export function parseIndex() {
81
85
  cache = { mtimeMs, size, entries };
82
86
  return entries;
83
87
  }
84
- /** Parse YAML frontmatter (very simple — supports key: value and key: [a, b]). */
85
- function parseFrontmatter(content) {
86
- const m = content.match(/^---\s*\n([\s\S]*?)\n---/);
87
- if (!m)
88
- return {};
89
- const out = {};
90
- for (const line of m[1].split("\n")) {
91
- const idx = line.indexOf(":");
92
- if (idx <= 0)
93
- continue;
94
- const key = line.slice(0, idx).trim();
95
- let value = line.slice(idx + 1).trim();
96
- if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
97
- value = value.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
98
- }
99
- else if (typeof value === "string") {
100
- value = value.replace(/^['"]|['"]$/g, "");
101
- }
102
- out[key] = value;
103
- }
104
- return out;
105
- }
106
88
  /** Build (or refresh) an IndexEntry by reading the page from disk. */
107
89
  export function buildIndexEntryForPage(path, fallback) {
108
90
  const normalizedPath = normalizeWikiPath(path);
109
91
  const content = readPage(normalizedPath);
110
92
  if (!content)
111
93
  return undefined;
112
- const fm = parseFrontmatter(content);
113
- const title = (typeof fm.title === "string" && fm.title) || fallback?.title || basenameTitle(normalizedPath);
114
- const tags = Array.isArray(fm.tags) ? fm.tags : (fallback?.tags ?? []);
115
- const updated = (typeof fm.updated === "string" && fm.updated) || fallback?.updated;
116
- // Summary heuristic: existing summary if provided, else first non-frontmatter
117
- // non-heading content line trimmed to 160 chars.
118
- 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() || "";
119
101
  if (!summary) {
120
- const body = content.replace(/^---[\s\S]*?---\s*/, "");
121
102
  for (const raw of body.split("\n")) {
122
103
  const line = raw.trim();
123
- if (!line || line.startsWith("#"))
104
+ if (!line || line.startsWith("#") || line.startsWith("<!--"))
124
105
  continue;
125
106
  summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
107
+ if (!summary)
108
+ continue;
126
109
  break;
127
110
  }
128
111
  }
@@ -145,7 +128,7 @@ function basenameTitle(path) {
145
128
  }
146
129
  /** Rebuild every index entry from on-disk pages. Preserves section if known. */
147
130
  export function rebuildIndexFromPages() {
148
- const pages = listPages();
131
+ const pages = listPages().filter((path) => !isActionLogPage(path));
149
132
  const previous = new Map();
150
133
  // Try to keep section assignments by re-parsing the (possibly-corrupted) index without recursion.
151
134
  try {
@@ -157,7 +140,7 @@ export function rebuildIndexFromPages() {
157
140
  section = sm[1].trim();
158
141
  continue;
159
142
  }
160
- const em = line.match(/^-\s+\[.+?\]\((.+?)\)/);
143
+ const em = line.match(/^\s*-\s+\[.+?\]\((.+?)\)/);
161
144
  if (em) {
162
145
  const normalizedPath = normalizeWikiPath(em[1].trim());
163
146
  previous.set(normalizedPath, { path: normalizedPath, title: "", summary: "", section });
@@ -181,13 +164,51 @@ export function writeIndex(entries) {
181
164
  writeIndexInternal(entries);
182
165
  invalidateCache();
183
166
  }
167
+ /** Derive the top-level category of a page from its path under pages/. */
168
+ function categoryOfPath(path) {
169
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
170
+ const segs = rest.split("/").filter(Boolean);
171
+ if (segs.length <= 1)
172
+ return (segs[0] || "pages").replace(/\.md$/i, "");
173
+ return segs[0];
174
+ }
175
+ /** Derive the topic slug of an entity-category page (pages/<cat>/<topic>/<file>), if any. */
176
+ function topicOfPath(path) {
177
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
178
+ const segs = rest.split("/").filter(Boolean);
179
+ return segs.length >= 3 ? segs[1] : undefined;
180
+ }
181
+ function isTopicIndexFile(path) {
182
+ return /\/index\.md$/i.test(path);
183
+ }
184
+ function humanize(slug) {
185
+ return slug.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
186
+ }
187
+ function renderEntryLine(item, indent = "") {
188
+ let line = `${indent}- [${item.title}](${item.path}) — ${item.summary}`;
189
+ if (item.tags?.length)
190
+ line += ` | tags: ${item.tags.join(", ")}`;
191
+ if (item.updated)
192
+ line += ` | updated: ${item.updated}`;
193
+ return line;
194
+ }
184
195
  function writeIndexInternal(entries) {
185
- const sections = new Map();
196
+ // Group by top-level category derived from the page path (not the stored
197
+ // `section`, which is no longer authoritative).
198
+ const byCategory = new Map();
186
199
  for (const entry of entries) {
187
- const list = sections.get(entry.section) || [];
200
+ const cat = categoryOfPath(entry.path);
201
+ const list = byCategory.get(cat) || [];
188
202
  list.push(entry);
189
- sections.set(entry.section, list);
203
+ byCategory.set(cat, list);
190
204
  }
205
+ const entityCats = entityCategories();
206
+ const entityCatSet = new Set(entityCats);
207
+ const known = [...entityCats, ...FLAT_CATEGORIES];
208
+ const orderedCategories = [
209
+ ...known.filter((c) => byCategory.has(c)),
210
+ ...[...byCategory.keys()].filter((c) => !known.includes(c)).sort(),
211
+ ];
191
212
  const lines = [
192
213
  "# Wiki Index",
193
214
  "",
@@ -196,23 +217,59 @@ function writeIndexInternal(entries) {
196
217
  `Last updated: ${new Date().toISOString().slice(0, 10)}`,
197
218
  "",
198
219
  ];
199
- for (const [section, items] of sections) {
200
- lines.push(`## ${section}`, "");
201
- for (const item of items) {
202
- let line = `- [${item.title}](${item.path}) — ${item.summary}`;
203
- if (item.tags?.length)
204
- line += ` | tags: ${item.tags.join(", ")}`;
205
- if (item.updated)
206
- line += ` | updated: ${item.updated}`;
207
- lines.push(line);
220
+ for (const cat of orderedCategories) {
221
+ const items = byCategory.get(cat);
222
+ lines.push(`## ${humanize(cat)}`, "");
223
+ if (entityCatSet.has(cat)) {
224
+ // Two-level layout: topic directory -> overview (index.md) + facet pages.
225
+ // Pages not yet in canonical <category>/<topic>/<file> shape are listed as
226
+ // plain bullets (they'll be relocated by the topic-structure migration).
227
+ const byTopic = new Map();
228
+ const ungrouped = [];
229
+ for (const entry of items) {
230
+ const topic = topicOfPath(entry.path);
231
+ if (!topic) {
232
+ ungrouped.push(entry);
233
+ continue;
234
+ }
235
+ const list = byTopic.get(topic) || [];
236
+ list.push(entry);
237
+ byTopic.set(topic, list);
238
+ }
239
+ for (const topic of [...byTopic.keys()].sort()) {
240
+ const topicItems = byTopic.get(topic);
241
+ const overview = topicItems.find((e) => isTopicIndexFile(e.path));
242
+ const facets = topicItems
243
+ .filter((e) => !isTopicIndexFile(e.path))
244
+ .sort((a, b) => a.path.localeCompare(b.path));
245
+ if (overview) {
246
+ lines.push(renderEntryLine(overview));
247
+ }
248
+ else {
249
+ lines.push(`- **${humanize(topic)}** _(no overview page)_`);
250
+ }
251
+ for (const facet of facets)
252
+ lines.push(renderEntryLine(facet, " "));
253
+ }
254
+ for (const entry of ungrouped.sort((a, b) => a.path.localeCompare(b.path))) {
255
+ lines.push(renderEntryLine(entry));
256
+ }
257
+ }
258
+ else {
259
+ for (const entry of [...items].sort((a, b) => a.path.localeCompare(b.path))) {
260
+ lines.push(renderEntryLine(entry));
261
+ }
208
262
  }
209
263
  lines.push("");
210
264
  }
211
- if (sections.size === 0) {
265
+ if (orderedCategories.length === 0) {
212
266
  lines.push("## Pages", "", "_(No pages yet.)_", "");
213
267
  }
214
268
  writeIndexFile(lines.join("\n"));
215
269
  }
270
+ function isActionLogPage(path) {
271
+ return ACTION_LOG_PAGE_RE.test(path);
272
+ }
216
273
  /** Add or update an entry in the index. Upserts by path. */
217
274
  export function addToIndex(entry) {
218
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",
@@ -73,6 +73,25 @@ test("parseIndex self-heals an empty index from on-disk pages", async () => {
73
73
  ]);
74
74
  assert.match(wikiFs.readIndexFile(), /\[Vision\]\(pages\/team\/vision\.md\)/);
75
75
  });
76
+ test("the index renders entity categories as topic groups with nested facet pages", async () => {
77
+ const { indexManager, wikiFs } = await loadModules();
78
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", "---\ntitle: Chapterhouse\nupdated: 2026-05-09\n---\n\n# Chapterhouse\n\nThe per-session orchestrator.\n");
79
+ wikiFs.writePage("pages/projects/chapterhouse/decisions.md", "---\ntitle: Chapterhouse Decisions\nupdated: 2026-05-09\n---\n\n# Decisions\n\nUse SSE for streaming.\n");
80
+ wikiFs.writePage("pages/preferences.md", "---\ntitle: Preferences\n---\n\n# Preferences\n\nDark mode.\n");
81
+ indexManager.rebuildIndexFromPages();
82
+ const index = wikiFs.readIndexFile();
83
+ assert.match(index, /## Projects/);
84
+ assert.match(index, /^- \[Chapterhouse\]\(pages\/projects\/chapterhouse\/index\.md\) — /m);
85
+ assert.match(index, /^ {2}- \[Chapterhouse Decisions\]\(pages\/projects\/chapterhouse\/decisions\.md\) — /m);
86
+ assert.match(index, /## Preferences/);
87
+ // Indented facet bullets must still round-trip through parseIndex.
88
+ const paths = indexManager.parseIndex().map((entry) => entry.path).sort();
89
+ assert.deepEqual(paths, [
90
+ "pages/preferences.md",
91
+ "pages/projects/chapterhouse/decisions.md",
92
+ "pages/projects/chapterhouse/index.md",
93
+ ]);
94
+ });
76
95
  test("searchIndex ranks strong metadata matches and falls back to page bodies", async () => {
77
96
  const { indexManager, wikiFs } = await loadModules();
78
97
  wikiFs.writePage("pages/team/api.md", "# API\n\nObservability budget and telemetry plans.\n");