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.
- package/dist/config.js +12 -1
- package/dist/copilot/system-message.js +7 -0
- package/dist/copilot/tools.js +81 -76
- package/dist/copilot/tools.wiki.test.js +143 -0
- package/dist/daemon.js +6 -0
- package/dist/wiki/frontmatter.js +148 -0
- package/dist/wiki/frontmatter.test.js +109 -0
- package/dist/wiki/fs.js +8 -3
- package/dist/wiki/fs.test.js +3 -2
- package/dist/wiki/index-manager.js +106 -49
- package/dist/wiki/index-manager.test.js +22 -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/migrate-topics.js +132 -0
- package/dist/wiki/migrate-topics.test.js +57 -0
- package/dist/wiki/taxonomy.js +73 -0
- package/dist/wiki/taxonomy.test.js +70 -0
- package/dist/wiki/topic-structure.js +167 -0
- package/dist/wiki/topic-structure.test.js +74 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BlIWCM11.js → index-BYuMgJ36.js} +61 -61
- package/web/dist/assets/index-BYuMgJ36.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BlIWCM11.js.map +0 -1
|
@@ -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(
|
|
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
|
-
|
|
68
|
+
_Append-only record of wiki operations._
|
|
64
69
|
|
|
65
70
|
`;
|
|
66
71
|
/**
|
package/dist/wiki/fs.test.js
CHANGED
|
@@ -20,11 +20,12 @@ test("wiki fs creates the wiki structure and supports page CRUD", async () => {
|
|
|
20
20
|
assert.equal(wiki.ensureWikiStructure(), true);
|
|
21
21
|
assert.equal(wiki.ensureWikiStructure(), false);
|
|
22
22
|
assert.equal(existsSync(join(wikiDir, "index.md")), true);
|
|
23
|
-
assert.equal(existsSync(join(wikiDir, "log.md")), true);
|
|
23
|
+
assert.equal(existsSync(join(wikiDir, "pages", "_meta", "log.md")), true);
|
|
24
24
|
wiki.writePage("pages/shared/runbooks/deploy.md", "# Deploy\n");
|
|
25
25
|
assert.equal(wiki.pageExists("pages/shared/runbooks/deploy.md"), true);
|
|
26
26
|
assert.equal(wiki.readPage("pages/shared/runbooks/deploy.md"), "# Deploy\n");
|
|
27
|
-
assert.
|
|
27
|
+
assert.equal(wiki.listPages().includes("pages/shared/runbooks/deploy.md"), true);
|
|
28
|
+
assert.match(wiki.readLogFile(), /^# Wiki Action Log/m);
|
|
28
29
|
assert.equal(wiki.deletePage("pages/shared/runbooks/deploy.md"), true);
|
|
29
30
|
assert.equal(wiki.deletePage("pages/shared/runbooks/deploy.md"), false);
|
|
30
31
|
assert.equal(wiki.readPage("pages/shared/runbooks/deploy.md"), undefined);
|
|
@@ -5,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
|
|
43
|
-
|
|
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 =
|
|
113
|
-
const title =
|
|
114
|
-
const tags =
|
|
115
|
-
const updated =
|
|
116
|
-
//
|
|
117
|
-
// non-heading content line
|
|
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(
|
|
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
|
-
|
|
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
|
|
200
|
+
const cat = categoryOfPath(entry.path);
|
|
201
|
+
const list = byCategory.get(cat) || [];
|
|
188
202
|
list.push(entry);
|
|
189
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 (
|
|
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
|
|
46
|
+
test("buildIndexEntryForPage treats frontmatter summary as the canonical index summary", async () => {
|
|
47
47
|
const { indexManager, wikiFs } = await loadModules();
|
|
48
|
-
wikiFs.writePage("pages/shared/runbooks/deploy.md", `---\ntitle: Deploy Runbook\ntags: [ops, release]\nupdated: 2026-05-04\n---\n\n# Deploy\n\n${"Deploy carefully ".repeat(20)}\n`);
|
|
48
|
+
wikiFs.writePage("pages/shared/runbooks/deploy.md", `---\ntitle: Deploy Runbook\nsummary: Production deployment checklist\ntags: [ops, release]\nupdated: 2026-05-04\n---\n\n# Deploy\n\n${"Deploy carefully ".repeat(20)}\n`);
|
|
49
49
|
const entry = indexManager.buildIndexEntryForPage("pages/shared/runbooks/deploy.md");
|
|
50
50
|
assert.deepEqual(entry, {
|
|
51
51
|
path: "pages/shared/runbooks/deploy.md",
|
|
52
52
|
title: "Deploy Runbook",
|
|
53
|
-
summary:
|
|
53
|
+
summary: "Production deployment checklist",
|
|
54
54
|
section: "Knowledge",
|
|
55
55
|
tags: ["ops", "release"],
|
|
56
56
|
updated: "2026-05-04",
|
|
@@ -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");
|