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.
@@ -0,0 +1,260 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync, utimesSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `wiki-lint-${process.pid}`);
7
+ const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ async function loadModules() {
10
+ const nonce = `${Date.now()}-${Math.random()}`;
11
+ const lint = await import(new URL(`./lint.js?case=${nonce}`, import.meta.url).href);
12
+ const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
13
+ return { lint, wikiFs };
14
+ }
15
+ function resetSandbox() {
16
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
17
+ rmSync(sandboxRoot, { recursive: true, force: true });
18
+ }
19
+ test.beforeEach(() => {
20
+ resetSandbox();
21
+ });
22
+ test.after(() => {
23
+ rmSync(sandboxRoot, { recursive: true, force: true });
24
+ });
25
+ function wikiPage(frontmatter, body) {
26
+ return `---\n${frontmatter.join("\n")}\n---\n\n${body.trim()}\n`;
27
+ }
28
+ function setPageAge(relativePath, ageInDays) {
29
+ const stamped = new Date(Date.now() - ageInDays * 24 * 60 * 60 * 1000);
30
+ const fullPath = join(wikiDir, relativePath);
31
+ utimesSync(fullPath, stamped, stamped);
32
+ }
33
+ test("lintWiki reports orphan pages and index entries pointing to missing pages", async () => {
34
+ const { lint, wikiFs } = await loadModules();
35
+ wikiFs.ensureWikiStructure();
36
+ wikiFs.writePage("pages/shared/orphan.md", wikiPage([
37
+ "title: Orphan",
38
+ "summary: Not indexed yet",
39
+ "updated: 2026-05-12",
40
+ "tags: [engineering]",
41
+ ], "# Orphan\n\nThis orphan page has enough descriptive body text to avoid the premature-page lint while still remaining absent from the index."));
42
+ wikiFs.writeIndexFile(`# Wiki Index
43
+
44
+ ## Shared
45
+
46
+ - [Missing](pages/shared/missing.md) — Not on disk
47
+ `);
48
+ const report = lint.lintWiki();
49
+ assert.equal(report.pageCount, 1);
50
+ assert.equal(report.sourceCount, 0);
51
+ assert.deepEqual(report.issues.map((issue) => `${issue.rule}:${issue.severity}:${issue.path ?? "-"}`).sort(), [
52
+ "missing-page:warning:pages/shared/missing.md",
53
+ "orphan-page:warning:pages/shared/orphan.md",
54
+ ]);
55
+ });
56
+ test("renderWikiLintReport preserves the current wiki_lint report format", async () => {
57
+ const { lint, wikiFs } = await loadModules();
58
+ wikiFs.ensureWikiStructure();
59
+ wikiFs.writePage("pages/shared/orphan.md", wikiPage([
60
+ "title: Orphan",
61
+ "summary: Not indexed yet",
62
+ "updated: 2026-05-12",
63
+ "tags: [engineering]",
64
+ ], "# Orphan\n\nThis orphan page has enough descriptive body text to avoid the premature-page lint while still remaining absent from the index."));
65
+ wikiFs.writeIndexFile(`# Wiki Index
66
+
67
+ ## Shared
68
+
69
+ - [Tracked](pages/shared/tracked.md) — Present only in the index
70
+ `);
71
+ const rendered = lint.renderWikiLintReport(lint.lintWiki());
72
+ assert.match(rendered, /^Wiki health report \(1 pages, 0 sources\):/);
73
+ assert.match(rendered, /warning\s+\|\s+orphan-page\s+\|\s+pages\/shared\/orphan\.md/);
74
+ assert.match(rendered, /warning\s+\|\s+missing-page\s+\|\s+pages\/shared\/tracked\.md/);
75
+ assert.match(rendered, /\*\*Suggestions\*\*: Look for pages that should link to each other/);
76
+ });
77
+ test("renderWikiLintReport reports the healthy wiki message when no issues are found", async () => {
78
+ const { lint, wikiFs } = await loadModules();
79
+ wikiFs.ensureWikiStructure();
80
+ wikiFs.writePage("pages/shared/indexed.md", wikiPage([
81
+ "title: Indexed",
82
+ "summary: Tracked page",
83
+ "updated: 2026-05-12",
84
+ "tags: [engineering]",
85
+ ], "# Indexed\n\n## Overview\n\nTracked page with enough detail to stay above the premature-page threshold and remain clearly established as a real page in the healthy-wiki case."));
86
+ wikiFs.writeIndexFile(`# Wiki Index
87
+
88
+ ## Shared
89
+
90
+ - [Indexed](pages/shared/indexed.md) — Tracked page
91
+ `);
92
+ const rendered = lint.renderWikiLintReport(lint.lintWiki());
93
+ assert.match(rendered, /✅ No issues found\. Index and pages are in sync\./);
94
+ });
95
+ test("lintWiki reports the remaining Phase 1 structural and content rules", async () => {
96
+ const { lint, wikiFs } = await loadModules();
97
+ wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
98
+ - engineering
99
+ - release
100
+
101
+ ## Unused
102
+ - ghost-tag
103
+ `);
104
+ wikiFs.writePage("pages/projects/chapterhouse/feature-ideas.md", wikiPage([
105
+ "title: Chapterhouse ideas",
106
+ "summary: Candidate follow-up work",
107
+ "updated: 2026-05-12",
108
+ "tags: [engineering]",
109
+ ], "# Chapterhouse ideas\n\n## Ideas\n\n- Add a conventions skill."));
110
+ setPageAge("pages/projects/chapterhouse/feature-ideas.md", 91);
111
+ wikiFs.writePage("pages/shared/missing-updated.md", wikiPage([
112
+ "title: Missing updated",
113
+ "summary: This page omits the updated field",
114
+ "tags: [engineering]",
115
+ ], "# Missing updated\n\nThis page forgot its date stamp."));
116
+ wikiFs.writePage("pages/shared/invalid-frontmatter.md", wikiPage([
117
+ "title: Invalid frontmatter",
118
+ "summary: **Not plain text**",
119
+ "tags: [engineering, unknown-tag]",
120
+ ], "# Invalid frontmatter\n\nBody content."));
121
+ wikiFs.writePage("pages/shared/skipped-heading.md", wikiPage([
122
+ "title: Skipped heading",
123
+ "summary: Heading depth should not skip levels",
124
+ "updated: 2026-05-12",
125
+ "tags: [engineering]",
126
+ ], "# Skipped heading\n\n### Too deep"));
127
+ wikiFs.writePage("pages/shared/decisions.md", wikiPage([
128
+ "title: Misfiled decisions",
129
+ "summary: Decisions must live under an entity directory",
130
+ "updated: 2026-05-12",
131
+ "tags: [decision]",
132
+ ], "# Misfiled decisions\n\n## Record\n\nA misplaced decision log."));
133
+ wikiFs.writePage("pages/shared/large-warning.md", wikiPage([
134
+ "title: Large warning",
135
+ "summary: Page size warning threshold coverage",
136
+ "updated: 2026-05-12",
137
+ "tags: [engineering]",
138
+ ], `# Large warning\n\n${Array.from({ length: 301 }, (_, index) => `Line ${index + 1}`).join("\n")}`));
139
+ wikiFs.writePage("pages/shared/large-error.md", wikiPage([
140
+ "title: Large error",
141
+ "summary: Page size error threshold coverage",
142
+ "updated: 2026-05-12",
143
+ "tags: [engineering]",
144
+ ], `# Large error\n\n${Array.from({ length: 801 }, (_, index) => `Line ${index + 1}`).join("\n")}`));
145
+ wikiFs.writePage("pages/shared/tiny.md", wikiPage([
146
+ "title: Tiny page",
147
+ "summary: Tiny pages should be linted as premature",
148
+ "updated: 2026-05-12",
149
+ "tags: [engineering]",
150
+ ], "# Tiny page\n\nTiny."));
151
+ wikiFs.writePage("pages/shared/stub.md", wikiPage([
152
+ "title: Stub page",
153
+ "summary: Stub pages should be flipped once they grow up",
154
+ "autostub: true",
155
+ "tags: [autostub]",
156
+ ], `# Stub page\n\n${Array.from({ length: 12 }, (_, index) => `- fleshed out line ${index + 1}`).join("\n")}`));
157
+ const report = lint.lintWiki();
158
+ const signatures = report.issues.map((issue) => `${issue.rule}:${issue.severity}:${issue.path ?? "-"}`);
159
+ assert.ok(signatures.includes("index-integrity:error:pages/projects/chapterhouse"));
160
+ assert.ok(signatures.includes("stale-page:warning:pages/projects/chapterhouse/feature-ideas.md"));
161
+ assert.ok(signatures.includes("missing-updated:warning:pages/shared/missing-updated.md"));
162
+ assert.ok(signatures.includes("frontmatter-shape:error:pages/shared/invalid-frontmatter.md"));
163
+ assert.ok(signatures.includes("heading-depth:warning:pages/shared/skipped-heading.md"));
164
+ assert.ok(signatures.includes("decision-misfile:error:pages/shared/decisions.md"));
165
+ assert.ok(signatures.includes("page-size:warning:pages/shared/large-warning.md"));
166
+ assert.ok(signatures.includes("page-size:error:pages/shared/large-error.md"));
167
+ assert.ok(signatures.includes("premature-page:info:pages/shared/tiny.md"));
168
+ assert.ok(signatures.includes("autostub-not-flipped:info:pages/shared/stub.md"));
169
+ assert.ok(signatures.includes("dead-taxonomy-entry:info:pages/_meta/taxonomy.md"));
170
+ });
171
+ test("lintWiki exempts decision and conversation pages from staleness and exempts autostubs from selected rules", async () => {
172
+ const { lint, wikiFs } = await loadModules();
173
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", wikiPage([
174
+ "title: Chapterhouse",
175
+ "summary: Overview page required for entity facets",
176
+ "updated: 2026-05-12",
177
+ "tags: [engineering]",
178
+ ], "# Chapterhouse\n\n## Overview\n\nProject overview."));
179
+ wikiFs.writePage("pages/projects/chapterhouse/decisions.md", wikiPage([
180
+ "title: Chapterhouse decisions",
181
+ "summary: Decision logs should never go stale",
182
+ "updated: 2026-05-12",
183
+ "tags: [decision]",
184
+ ], "# Chapterhouse decisions\n\n## Decisions\n\nRecorded decisions."));
185
+ setPageAge("pages/projects/chapterhouse/decisions.md", 365);
186
+ wikiFs.writePage("pages/conversations/2026-01-01.md", wikiPage([
187
+ "title: Conversations on 2026-01-01",
188
+ "summary: Daily conversation summary",
189
+ "updated: 2026-01-01",
190
+ "tags: [engineering]",
191
+ ], "# Conversations on 2026-01-01\n\nSummary body."));
192
+ setPageAge("pages/conversations/2026-01-01.md", 365);
193
+ wikiFs.writePage("pages/shared/autostub.md", wikiPage([
194
+ "title: Stub",
195
+ "summary: Placeholder page",
196
+ "autostub: true",
197
+ "tags: [autostub]",
198
+ ], `# Stub\n\n### Placeholder\n\n${Array.from({ length: 900 }, (_, index) => `Line ${index + 1}`).join("\n")}`));
199
+ const report = lint.lintWiki();
200
+ const signatures = report.issues.map((issue) => `${issue.rule}:${issue.path ?? "-"}`);
201
+ assert.equal(signatures.includes("stale-page:pages/projects/chapterhouse/decisions.md"), false);
202
+ assert.equal(signatures.includes("stale-page:pages/conversations/2026-01-01.md"), false);
203
+ assert.equal(signatures.includes("missing-updated:pages/shared/autostub.md"), false);
204
+ assert.equal(signatures.includes("premature-page:pages/shared/autostub.md"), false);
205
+ assert.equal(signatures.includes("heading-depth:pages/shared/autostub.md"), false);
206
+ assert.equal(signatures.includes("page-size:pages/shared/autostub.md"), false);
207
+ });
208
+ test("renderWikiLintReport surfaces contested and low-confidence pages ahead of the general issue list", async () => {
209
+ const { lint, wikiFs } = await loadModules();
210
+ wikiFs.writePage("pages/shared/contested.md", wikiPage([
211
+ "title: Contested page",
212
+ "summary: This page is explicitly contested",
213
+ "updated: 2026-05-12",
214
+ "tags: [engineering]",
215
+ "contested: true",
216
+ ], "# Contested page\n\nThis page needs review."));
217
+ wikiFs.writePage("pages/shared/low-confidence.md", wikiPage([
218
+ "title: Low confidence page",
219
+ "summary: This page has low confidence",
220
+ "updated: 2026-05-12",
221
+ "tags: [engineering]",
222
+ "confidence: low",
223
+ ], "# Low confidence page\n\nThis page needs review."));
224
+ wikiFs.writePage("pages/shared/missing-updated.md", wikiPage([
225
+ "title: Missing updated",
226
+ "summary: A regular issue should come after contested pages",
227
+ "tags: [engineering]",
228
+ ], "# Missing updated\n\nBody."));
229
+ const rendered = lint.renderWikiLintReport(lint.lintWiki());
230
+ const contestedIndex = rendered.indexOf("pages/shared/contested.md");
231
+ const lowConfidenceIndex = rendered.indexOf("pages/shared/low-confidence.md");
232
+ const generalIssueIndex = rendered.indexOf("missing-updated");
233
+ assert.ok(contestedIndex >= 0);
234
+ assert.ok(lowConfidenceIndex >= 0);
235
+ assert.ok(generalIssueIndex >= 0);
236
+ assert.ok(contestedIndex < generalIssueIndex);
237
+ assert.ok(lowConfidenceIndex < generalIssueIndex);
238
+ });
239
+ test("lintWiki reports a missing or unwritable action log", async () => {
240
+ const { lint, wikiFs } = await loadModules();
241
+ wikiFs.ensureWikiStructure();
242
+ wikiFs.writePage("pages/shared/healthy.md", wikiPage([
243
+ "title: Healthy page",
244
+ "summary: Enough content to avoid incidental lint issues",
245
+ "updated: 2026-05-12",
246
+ "tags: [engineering]",
247
+ ], "# Healthy page\n\n## Overview\n\nThis page exists so the action-log rule is isolated from unrelated findings in the test."));
248
+ rmSync(join(wikiDir, "pages", "_meta", "log.md"), { force: true });
249
+ let report = lint.lintWiki();
250
+ assert.ok(report.issues.some((issue) => issue.rule === "action-log" &&
251
+ issue.severity === "error" &&
252
+ issue.path === "pages/_meta/log.md"));
253
+ rmSync(join(wikiDir, "pages", "_meta"), { recursive: true, force: true });
254
+ mkdirSync(join(wikiDir, "pages", "_meta", "log.md"), { recursive: true });
255
+ report = lint.lintWiki();
256
+ assert.ok(report.issues.some((issue) => issue.rule === "action-log" &&
257
+ issue.severity === "error" &&
258
+ issue.path === "pages/_meta/log.md"));
259
+ });
260
+ //# sourceMappingURL=lint.test.js.map
@@ -1,20 +1,63 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Wiki log.md manager — append-only chronological operation log
2
+ // Wiki action-log manager — append-only chronological operation log
3
3
  // ---------------------------------------------------------------------------
4
- import { appendFileSync } from "fs";
4
+ import { appendFileSync, existsSync, readFileSync, renameSync } from "fs";
5
5
  import { join } from "path";
6
- import { WIKI_DIR } from "../paths.js";
7
- import { ensureWikiStructure } from "./fs.js";
8
- const LOG_PATH = join(WIKI_DIR, "log.md");
6
+ import { WIKI_PAGES_DIR } from "../paths.js";
7
+ import { ensureWikiStructure, writeFileAtomic } from "./fs.js";
8
+ export const ACTION_LOG_PATH = "pages/_meta/log.md";
9
+ const LOG_PATH = join(WIKI_PAGES_DIR, "_meta", "log.md");
10
+ const MAX_LOG_ENTRIES = 500;
11
+ const LOG_ENTRY_RE = /^## \[/gm;
12
+ const INITIAL_LOG = `# Wiki Action Log
13
+
14
+ _Append-only record of wiki operations._
15
+
16
+ `;
9
17
  /**
10
- * Append a timestamped entry to log.md.
11
- * Format: `## [YYYY-MM-DD HH:MM] type | description`
18
+ * Append a timestamped entry to pages/_meta/log.md.
19
+ * Format: `## [YYYY-MM-DD HH:MM] action | subject | agent`
12
20
  */
13
- export function appendLog(type, description) {
21
+ export function appendLog(type, subject, agent = resolveAgentName()) {
14
22
  ensureWikiStructure();
23
+ rotateLogIfNeeded();
15
24
  const now = new Date();
16
25
  const ts = now.toISOString().slice(0, 16).replace("T", " ");
17
- const entry = `## [${ts}] ${type} | ${description}\n\n`;
26
+ const entry = `## [${ts}] ${type} | ${subject} | ${agent}\n\n`;
18
27
  appendFileSync(LOG_PATH, entry, "utf-8");
19
28
  }
29
+ function rotateLogIfNeeded() {
30
+ const currentLog = readFileSync(LOG_PATH, "utf-8");
31
+ const entryCount = (currentLog.match(LOG_ENTRY_RE) ?? []).length;
32
+ if (entryCount < MAX_LOG_ENTRIES) {
33
+ return;
34
+ }
35
+ const archivePath = join(WIKI_PAGES_DIR, "_meta", `log-${new Date().getFullYear()}.md`);
36
+ if (!existsSync(archivePath)) {
37
+ renameSync(LOG_PATH, archivePath);
38
+ }
39
+ else {
40
+ const archivedEntries = extractEntries(currentLog);
41
+ if (archivedEntries.length > 0) {
42
+ const separator = readFileSync(archivePath, "utf-8").endsWith("\n\n") ? "" : "\n\n";
43
+ appendFileSync(archivePath, `${separator}${archivedEntries.join("\n\n")}\n\n`, "utf-8");
44
+ }
45
+ }
46
+ writeFileAtomic(LOG_PATH, INITIAL_LOG);
47
+ }
48
+ function extractEntries(content) {
49
+ return content
50
+ .split(/\n{2,}/)
51
+ .map((chunk) => chunk.trim())
52
+ .filter((chunk) => chunk.startsWith("## ["));
53
+ }
54
+ function resolveAgentName() {
55
+ const candidates = [
56
+ process.env.CHAPTERHOUSE_SESSION_AGENT_NAME,
57
+ process.env.CHAPTERHOUSE_AGENT_NAME,
58
+ process.env.COPILOT_AGENT_NAME,
59
+ process.env.AGENT_NAME,
60
+ ];
61
+ return candidates.find((candidate) => candidate && candidate.trim().length > 0)?.trim() ?? "unknown";
62
+ }
20
63
  //# sourceMappingURL=log-manager.js.map
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `wiki-log-${process.pid}`);
7
+ const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ process.env.CHAPTERHOUSE_AGENT_NAME = "wiki-test-agent";
10
+ async function loadModules() {
11
+ const nonce = `${Date.now()}-${Math.random()}`;
12
+ const logManager = await import(new URL(`./log-manager.js?case=${nonce}`, import.meta.url).href);
13
+ const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
14
+ return { logManager, wikiFs };
15
+ }
16
+ function resetSandbox() {
17
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
18
+ rmSync(sandboxRoot, { recursive: true, force: true });
19
+ }
20
+ test.beforeEach(() => {
21
+ resetSandbox();
22
+ });
23
+ test.after(() => {
24
+ rmSync(sandboxRoot, { recursive: true, force: true });
25
+ });
26
+ test("appendLog writes action entries to pages/_meta/log.md with the resolved agent name", async () => {
27
+ const { logManager, wikiFs } = await loadModules();
28
+ wikiFs.ensureWikiStructure();
29
+ logManager.appendLog("update", "wiki_update: Chapterhouse (pages/shared/chapterhouse.md)");
30
+ assert.equal(existsSync(join(wikiDir, "pages", "_meta", "log.md")), true);
31
+ assert.match(wikiFs.readLogFile(), /^## \[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| wiki-test-agent/m);
32
+ });
33
+ test("appendLog rotates the active log after 500 entries and keeps appending to a fresh log", async () => {
34
+ const { logManager, wikiFs } = await loadModules();
35
+ wikiFs.ensureWikiStructure();
36
+ wikiFs.writeLogFile("# Wiki Action Log\n\n" +
37
+ Array.from({ length: 500 }, (_, index) => `## [2026-01-01 00:00] update | historical entry ${index + 1} | wiki-test-agent\n\n`).join(""));
38
+ logManager.appendLog("update", "fresh entry after rotation");
39
+ const archiveYear = String(new Date().getFullYear());
40
+ const activeLog = wikiFs.readLogFile();
41
+ const archivedLog = wikiFs.readPage(`pages/_meta/log-${archiveYear}.md`);
42
+ assert.ok(archivedLog);
43
+ assert.match(archivedLog, /historical entry 1/);
44
+ assert.doesNotMatch(activeLog, /historical entry 1/);
45
+ assert.match(activeLog, /fresh entry after rotation/);
46
+ });
47
+ //# sourceMappingURL=log-manager.test.js.map
@@ -0,0 +1,73 @@
1
+ import { readPage } from "./fs.js";
2
+ const ENGINEERING_TAGS = [
3
+ "engineering",
4
+ "architecture",
5
+ "release",
6
+ "runbook",
7
+ "incident",
8
+ "orchestration",
9
+ ];
10
+ const PEOPLE_TAGS = [
11
+ "people",
12
+ "person",
13
+ "org",
14
+ "team",
15
+ "tool",
16
+ ];
17
+ const PROCESS_TAGS = [
18
+ "process",
19
+ "project",
20
+ "topic",
21
+ "area",
22
+ "decision",
23
+ "preference",
24
+ "fact",
25
+ "routine",
26
+ "research",
27
+ ];
28
+ const META_TAGS = [
29
+ "meta",
30
+ "index",
31
+ "source",
32
+ "taxonomy",
33
+ "autostub",
34
+ ];
35
+ export const DEFAULT_TAGS = [
36
+ ...ENGINEERING_TAGS,
37
+ ...PEOPLE_TAGS,
38
+ ...PROCESS_TAGS,
39
+ ...META_TAGS,
40
+ ];
41
+ export const TAXONOMY_PATH = "pages/_meta/taxonomy.md";
42
+ export function parseTaxonomyTags(content) {
43
+ const tags = new Set();
44
+ let seenHeading = false;
45
+ for (const rawLine of content.split("\n")) {
46
+ const line = rawLine.trim();
47
+ if (!line)
48
+ continue;
49
+ if (/^##\s+.+/.test(line)) {
50
+ seenHeading = true;
51
+ continue;
52
+ }
53
+ const bulletMatch = line.match(/^-\s+([a-z0-9][a-z0-9-]*)$/i);
54
+ if (bulletMatch && seenHeading) {
55
+ tags.add(bulletMatch[1].toLowerCase());
56
+ continue;
57
+ }
58
+ throw new Error(`Invalid wiki taxonomy at ${TAXONOMY_PATH}: expected '## Group' headings with '- tag' bullets.`);
59
+ }
60
+ return [...tags];
61
+ }
62
+ export function loadTaxonomy() {
63
+ const tags = new Set(DEFAULT_TAGS);
64
+ const override = readPage(TAXONOMY_PATH);
65
+ if (!override) {
66
+ return [...tags].sort();
67
+ }
68
+ for (const tag of parseTaxonomyTags(override)) {
69
+ tags.add(tag);
70
+ }
71
+ return [...tags].sort();
72
+ }
73
+ //# sourceMappingURL=taxonomy.js.map
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `wiki-taxonomy-${process.pid}`);
7
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
8
+ async function loadModules() {
9
+ const nonce = `${Date.now()}-${Math.random()}`;
10
+ const taxonomy = await import(new URL(`./taxonomy.js?case=${nonce}`, import.meta.url).href);
11
+ const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
12
+ return { taxonomy, wikiFs };
13
+ }
14
+ function resetSandbox() {
15
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
16
+ rmSync(sandboxRoot, { recursive: true, force: true });
17
+ }
18
+ test.beforeEach(() => {
19
+ resetSandbox();
20
+ });
21
+ test.after(() => {
22
+ rmSync(sandboxRoot, { recursive: true, force: true });
23
+ });
24
+ test("loadTaxonomy returns the default tags when no wiki taxonomy file exists", async () => {
25
+ const { taxonomy } = await loadModules();
26
+ const merged = taxonomy.loadTaxonomy();
27
+ assert.deepEqual(merged, [...taxonomy.DEFAULT_TAGS].sort());
28
+ });
29
+ test("loadTaxonomy merges pages/_meta/taxonomy.md entries with the defaults", async () => {
30
+ const { taxonomy, wikiFs } = await loadModules();
31
+ wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
32
+ - orchestration
33
+ - release
34
+
35
+ ## Custom Group
36
+ - teammate
37
+ `);
38
+ const merged = taxonomy.loadTaxonomy();
39
+ assert.equal(merged.includes("orchestration"), true);
40
+ assert.equal(merged.includes("release"), true);
41
+ assert.equal(merged.includes("teammate"), true);
42
+ assert.equal(merged.includes("engineering"), true);
43
+ });
44
+ test("loadTaxonomy deduplicates repeated tags across defaults and overrides", async () => {
45
+ const { taxonomy, wikiFs } = await loadModules();
46
+ wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
47
+ - engineering
48
+ - release
49
+ - release
50
+ `);
51
+ const merged = taxonomy.loadTaxonomy();
52
+ assert.equal(merged.filter((tag) => tag === "engineering").length, 1);
53
+ assert.equal(merged.filter((tag) => tag === "release").length, 1);
54
+ });
55
+ test("loadTaxonomy accepts arbitrary heading labels as grouping metadata", async () => {
56
+ const { taxonomy, wikiFs } = await loadModules();
57
+ wikiFs.writePage("pages/_meta/taxonomy.md", `## Totally New Bucket
58
+ - field-note
59
+ `);
60
+ const merged = taxonomy.loadTaxonomy();
61
+ assert.equal(merged.includes("field-note"), true);
62
+ });
63
+ test("loadTaxonomy rejects malformed taxonomy markdown clearly", async () => {
64
+ const { taxonomy, wikiFs } = await loadModules();
65
+ wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
66
+ not-a-bullet
67
+ `);
68
+ assert.throws(() => taxonomy.loadTaxonomy(), /Invalid wiki taxonomy/i);
69
+ });
70
+ //# sourceMappingURL=taxonomy.test.js.map
@@ -27,6 +27,9 @@ export const EXEMPT_PREFIXES = [
27
27
  "pages/kpis/",
28
28
  "pages/shared/",
29
29
  ];
30
+ export const EXEMPT_PAGES = [
31
+ "pages/index.md",
32
+ ];
30
33
  /** Map a `remember`-style category name to its directory under pages/. */
31
34
  const CATEGORY_DIR_MAP = {
32
35
  person: "people",
@@ -65,7 +68,8 @@ function isFlatCategory(dir) {
65
68
  return FLAT_CATEGORIES.includes(dir);
66
69
  }
67
70
  function isExemptPath(relativePath) {
68
- return EXEMPT_PREFIXES.some((p) => relativePath.startsWith(p));
71
+ return EXEMPT_PAGES.includes(relativePath)
72
+ || EXEMPT_PREFIXES.some((p) => relativePath.startsWith(p));
69
73
  }
70
74
  /** Slugify a free-text name into a wiki-safe path segment. */
71
75
  export function slugify(name) {
@@ -25,6 +25,7 @@ test("topicPagePath builds canonical paths", () => {
25
25
  assert.equal(topicPagePath("tool", "Kubernetes"), "pages/tools/kubernetes/index.md");
26
26
  });
27
27
  test("validateTopicPath accepts canonical and exempt paths", () => {
28
+ assert.deepEqual(validateTopicPath("pages/index.md"), { ok: true });
28
29
  assert.deepEqual(validateTopicPath("pages/projects/chapterhouse/index.md"), { ok: true });
29
30
  assert.deepEqual(validateTopicPath("pages/projects/chapterhouse/decisions.md"), { ok: true });
30
31
  assert.deepEqual(validateTopicPath("pages/people/brian/index.md"), { ok: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"