chapterhouse 0.3.17 → 0.3.18

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.
@@ -203,6 +203,20 @@ function assertValidPagePath(path) {
203
203
  function getWikiPageScope(path) {
204
204
  return teamWikiSync.isTeamPath(path) ? "team" : "personal";
205
205
  }
206
+ function getEmptyWikiWelcomeContent(today = new Date()) {
207
+ return `---
208
+ title: Wiki
209
+ summary: Empty wiki — get started.
210
+ updated: ${today.toISOString().slice(0, 10)}
211
+ ---
212
+
213
+ # Wiki
214
+
215
+ Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
216
+
217
+ Create your first page via the wiki UI or by editing files under \`pages/\`.
218
+ `;
219
+ }
206
220
  // Active SSE connections
207
221
  const sseClients = new Map();
208
222
  const pendingSseMessages = [];
@@ -746,6 +760,10 @@ app.get("/api/wiki/page", async (req, res) => {
746
760
  : undefined;
747
761
  const content = await readWikiPage(path, { authorizationHeader });
748
762
  if (content === undefined) {
763
+ if (path === "pages/index.md") {
764
+ res.json({ path, content: getEmptyWikiWelcomeContent() });
765
+ return;
766
+ }
749
767
  throw new NotFoundError("Page not found");
750
768
  }
751
769
  res.json({ path, content });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
- import { mkdirSync, rmSync } from "node:fs";
3
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
@@ -293,6 +293,62 @@ test("server wiki routes support authenticated CRUD", async () => {
293
293
  assert.deepEqual(await missingResponse.json(), { error: "Page not found" });
294
294
  });
295
295
  });
296
+ test("server wiki route synthesizes a welcome page when pages/index.md is missing", async () => {
297
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
298
+ rmSync(join(serverTestRoot, ".chapterhouse", "wiki", "pages", "index.md"), { force: true });
299
+ const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/index.md`, {
300
+ headers: { authorization: authHeader },
301
+ });
302
+ assert.equal(response.status, 200);
303
+ assert.deepEqual(await response.json(), {
304
+ path: "pages/index.md",
305
+ content: `---
306
+ title: Wiki
307
+ summary: Empty wiki — get started.
308
+ updated: ${new Date().toISOString().slice(0, 10)}
309
+ ---
310
+
311
+ # Wiki
312
+
313
+ Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
314
+
315
+ Create your first page via the wiki UI or by editing files under \`pages/\`.
316
+ `,
317
+ });
318
+ });
319
+ });
320
+ test("server wiki route returns stored content for pages/index.md when it exists", async () => {
321
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
322
+ const indexPath = join(serverTestRoot, ".chapterhouse", "wiki", "pages", "index.md");
323
+ const content = `---
324
+ title: Wiki
325
+ summary: Existing home page.
326
+ updated: 2026-05-12
327
+ ---
328
+
329
+ # Custom Wiki Home
330
+ `;
331
+ mkdirSync(join(serverTestRoot, ".chapterhouse", "wiki", "pages"), { recursive: true });
332
+ writeFileSync(indexPath, content, "utf-8");
333
+ const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/index.md`, {
334
+ headers: { authorization: authHeader },
335
+ });
336
+ assert.equal(response.status, 200);
337
+ assert.deepEqual(await response.json(), {
338
+ path: "pages/index.md",
339
+ content,
340
+ });
341
+ });
342
+ });
343
+ test("server wiki route still returns 404 for other missing wiki pages", async () => {
344
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
345
+ const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/projects/nonexistent/index.md`, {
346
+ headers: { authorization: authHeader },
347
+ });
348
+ assert.equal(response.status, 404);
349
+ assert.deepEqual(await response.json(), { error: "Page not found" });
350
+ });
351
+ });
296
352
  test("server message route validates the SSE connection id", async () => {
297
353
  await withStartedServer(async ({ baseUrl, authHeader }) => {
298
354
  const response = await fetch(`${baseUrl}/api/message`, {
package/dist/wiki/fs.js CHANGED
@@ -7,6 +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 PAGES_INDEX_PATH = join(WIKI_PAGES_DIR, "index.md");
10
11
  const LOG_PATH = join(WIKI_PAGES_DIR, "_meta", "log.md");
11
12
  /**
12
13
  * Write a file atomically: write to a temp file in the same directory, fsync,
@@ -63,6 +64,18 @@ Last updated: ${new Date().toISOString().slice(0, 10)}
63
64
  _(No pages yet.)_
64
65
  `;
65
66
  }
67
+ function getInitialPagesIndex() {
68
+ return `---
69
+ title: Wiki
70
+ summary: Index of all wiki pages.
71
+ updated: ${new Date().toISOString().slice(0, 10)}
72
+ ---
73
+
74
+ # Wiki
75
+
76
+ Welcome to your Chapterhouse wiki. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines. Use the navigation to explore, or search.
77
+ `;
78
+ }
66
79
  const INITIAL_LOG = `# Wiki Action Log
67
80
 
68
81
  _Append-only record of wiki operations._
@@ -79,6 +92,9 @@ export function ensureWikiStructure() {
79
92
  if (!existsSync(INDEX_PATH)) {
80
93
  writeFileAtomic(INDEX_PATH, getInitialIndex());
81
94
  }
95
+ if (!existsSync(PAGES_INDEX_PATH)) {
96
+ writeFileAtomic(PAGES_INDEX_PATH, getInitialPagesIndex());
97
+ }
82
98
  if (!existsSync(LOG_PATH)) {
83
99
  writeFileAtomic(LOG_PATH, INITIAL_LOG);
84
100
  }
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, mkdirSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import test from "node:test";
5
5
  const repoRoot = process.cwd();
@@ -20,6 +20,7 @@ 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, "pages", "index.md")), true);
23
24
  assert.equal(existsSync(join(wikiDir, "pages", "_meta", "log.md")), true);
24
25
  wiki.writePage("pages/shared/runbooks/deploy.md", "# Deploy\n");
25
26
  assert.equal(wiki.pageExists("pages/shared/runbooks/deploy.md"), true);
@@ -30,6 +31,23 @@ test("wiki fs creates the wiki structure and supports page CRUD", async () => {
30
31
  assert.equal(wiki.deletePage("pages/shared/runbooks/deploy.md"), false);
31
32
  assert.equal(wiki.readPage("pages/shared/runbooks/deploy.md"), undefined);
32
33
  });
34
+ test("wiki fs seeds pages/index.md when missing", async () => {
35
+ rmSync(sandboxRoot, { recursive: true, force: true });
36
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
37
+ const wiki = await loadFsModule();
38
+ wiki.ensureWikiStructure();
39
+ const pagesIndex = readFileSync(join(wikiDir, "pages", "index.md"), "utf-8");
40
+ assert.match(pagesIndex, /^---\n/m);
41
+ assert.match(pagesIndex, /^title: Wiki$/m);
42
+ });
43
+ test("wiki fs does not overwrite an existing pages/index.md", async () => {
44
+ rmSync(sandboxRoot, { recursive: true, force: true });
45
+ mkdirSync(join(wikiDir, "pages"), { recursive: true });
46
+ writeFileSync(join(wikiDir, "pages", "index.md"), "# Custom Wiki Home\n", "utf-8");
47
+ const wiki = await loadFsModule();
48
+ wiki.ensureWikiStructure();
49
+ assert.equal(readFileSync(join(wikiDir, "pages", "index.md"), "utf-8"), "# Custom Wiki Home\n");
50
+ });
33
51
  test("wiki fs rejects unsafe page paths", async () => {
34
52
  const wiki = await loadFsModule();
35
53
  assert.doesNotThrow(() => wiki.assertPagePath("pages/shared/runbooks/deploy.md"));
@@ -58,10 +58,19 @@ test("buildIndexEntryForPage treats frontmatter summary as the canonical index s
58
58
  });
59
59
  test("parseIndex self-heals an empty index from on-disk pages", async () => {
60
60
  const { indexManager, wikiFs } = await loadModules();
61
+ const today = new Date().toISOString().slice(0, 10);
61
62
  wikiFs.writePage("pages/team/vision.md", "# Vision\n\nShared direction for the team.\n");
62
63
  wikiFs.writeIndexFile("# Wiki Index\n\n");
63
64
  const entries = indexManager.parseIndex();
64
65
  assert.deepEqual(entries, [
66
+ {
67
+ path: "pages/index.md",
68
+ title: "Wiki",
69
+ summary: "Index of all wiki pages.",
70
+ section: "Knowledge",
71
+ tags: undefined,
72
+ updated: today,
73
+ },
65
74
  {
66
75
  path: "pages/team/vision.md",
67
76
  title: "Vision",
@@ -119,6 +128,7 @@ test("searchIndex ranks strong metadata matches and falls back to page bodies",
119
128
  });
120
129
  test("addToIndex, removeFromIndex, and getIndexSummary keep the catalog in sync", async () => {
121
130
  const { indexManager } = await loadModules();
131
+ const today = new Date().toISOString().slice(0, 10);
122
132
  indexManager.addToIndex({
123
133
  path: "pages/people/ada.md",
124
134
  title: "Ada Lovelace",
@@ -143,6 +153,8 @@ test("addToIndex, removeFromIndex, and getIndexSummary keep the catalog in sync"
143
153
  });
144
154
  assert.equal(indexManager.removeFromIndex("pages/projects/launch.md"), true);
145
155
  assert.equal(indexManager.removeFromIndex("pages/projects/missing.md"), false);
146
- assert.equal(indexManager.getIndexSummary(), "**People**: Ada Lovelace: Owns regression coverage [qa, testing] (2026-05-06)");
156
+ const summary = indexManager.getIndexSummary();
157
+ assert.match(summary, /\*\*People\*\*: Ada Lovelace: Owns regression coverage \[qa, testing\] \(2026-05-06\)/);
158
+ assert.match(summary, new RegExp(`\\*\\*Index\\*\\*: Wiki: Index of all wiki pages\\. \\(${today}\\)`));
147
159
  });
148
160
  //# sourceMappingURL=index-manager.test.js.map
@@ -25,6 +25,18 @@ test.after(() => {
25
25
  function wikiPage(frontmatter, body) {
26
26
  return `---\n${frontmatter.join("\n")}\n---\n\n${body.trim()}\n`;
27
27
  }
28
+ function wikiIndexWithSharedEntries(...entries) {
29
+ return `# Wiki Index
30
+
31
+ ## Index
32
+
33
+ - [Wiki](pages/index.md) — Index of all wiki pages.
34
+
35
+ ## Shared
36
+
37
+ ${entries.join("\n")}
38
+ `;
39
+ }
28
40
  function setPageAge(relativePath, ageInDays) {
29
41
  const stamped = new Date(Date.now() - ageInDays * 24 * 60 * 60 * 1000);
30
42
  const fullPath = join(wikiDir, relativePath);
@@ -39,14 +51,9 @@ test("lintWiki reports orphan pages and index entries pointing to missing pages"
39
51
  "updated: 2026-05-12",
40
52
  "tags: [engineering]",
41
53
  ], "# 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
- `);
54
+ wikiFs.writeIndexFile(wikiIndexWithSharedEntries("- [Missing](pages/shared/missing.md) — Not on disk"));
48
55
  const report = lint.lintWiki();
49
- assert.equal(report.pageCount, 1);
56
+ assert.equal(report.pageCount, 2);
50
57
  assert.equal(report.sourceCount, 0);
51
58
  assert.deepEqual(report.issues.map((issue) => `${issue.rule}:${issue.severity}:${issue.path ?? "-"}`).sort(), [
52
59
  "missing-page:warning:pages/shared/missing.md",
@@ -62,14 +69,9 @@ test("renderWikiLintReport preserves the current wiki_lint report format", async
62
69
  "updated: 2026-05-12",
63
70
  "tags: [engineering]",
64
71
  ], "# 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
- `);
72
+ wikiFs.writeIndexFile(wikiIndexWithSharedEntries("- [Tracked](pages/shared/tracked.md) — Present only in the index"));
71
73
  const rendered = lint.renderWikiLintReport(lint.lintWiki());
72
- assert.match(rendered, /^Wiki health report \(1 pages, 0 sources\):/);
74
+ assert.match(rendered, /^Wiki health report \(2 pages, 0 sources\):/);
73
75
  assert.match(rendered, /warning\s+\|\s+orphan-page\s+\|\s+pages\/shared\/orphan\.md/);
74
76
  assert.match(rendered, /warning\s+\|\s+missing-page\s+\|\s+pages\/shared\/tracked\.md/);
75
77
  assert.match(rendered, /\*\*Suggestions\*\*: Look for pages that should link to each other/);
@@ -83,12 +85,7 @@ test("renderWikiLintReport reports the healthy wiki message when no issues are f
83
85
  "updated: 2026-05-12",
84
86
  "tags: [engineering]",
85
87
  ], "# 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
- `);
88
+ wikiFs.writeIndexFile(wikiIndexWithSharedEntries("- [Indexed](pages/shared/indexed.md) — Tracked page"));
92
89
  const rendered = lint.renderWikiLintReport(lint.lintWiki());
93
90
  assert.match(rendered, /✅ No issues found\. Index and pages are in sync\./);
94
91
  });
@@ -21,6 +21,7 @@ import { normalizeWikiPath } from "./path-utils.js";
21
21
  export const FLAT_CATEGORIES = ["preferences", "facts", "routines"];
22
22
  /** Path prefixes (relative to the wiki root) that follow their own conventions. */
23
23
  export const EXEMPT_PREFIXES = [
24
+ "pages/_meta/",
24
25
  "pages/conversations/",
25
26
  "pages/team/",
26
27
  "pages/okrs/",
@@ -158,7 +159,8 @@ export function validateTopicPath(relativePath) {
158
159
  ok: false,
159
160
  reason: `'${topDir}' is not a recognized wiki category. ` +
160
161
  `Use one of: ${valid} (entity categories take a topic directory: pages/<category>/<topic>/<page>.md). ` +
161
- `Conversations and team pages are written by the system.`,
162
+ `Conversations and team pages are written by the system. ` +
163
+ `(pages/_meta/ is reserved for system metadata.)`,
162
164
  };
163
165
  }
164
166
  /** Convenience: returns the error message string for an invalid path, or undefined if valid. */
@@ -35,6 +35,16 @@ test("validateTopicPath accepts canonical and exempt paths", () => {
35
35
  assert.deepEqual(validateTopicPath("pages/team/onboarding.md"), { ok: true });
36
36
  assert.deepEqual(validateTopicPath("pages/okrs/2026-Q2.md"), { ok: true });
37
37
  });
38
+ test("validateTopicPath accepts system-managed pages/_meta subtree paths", () => {
39
+ assert.deepEqual(validateTopicPath("pages/_meta/log.md"), { ok: true });
40
+ assert.deepEqual(validateTopicPath("pages/_meta/taxonomy.md"), { ok: true });
41
+ assert.deepEqual(validateTopicPath("pages/_meta/log-2026.md"), { ok: true });
42
+ assert.deepEqual(validateTopicPath("pages/_meta/sub/file.md"), { ok: true });
43
+ });
44
+ test("validateTopicPath rejects pages/_meta without a page file", () => {
45
+ const r = validateTopicPath("pages/_meta");
46
+ assert.equal(r.ok, false);
47
+ });
38
48
  test("validateTopicPath rejects a bare entity-category file and suggests the topic index", () => {
39
49
  const r = validateTopicPath("pages/projects/chapterhouse.md");
40
50
  assert.equal(r.ok, false);
@@ -61,6 +71,7 @@ test("validateTopicPath rejects unknown top-level categories", () => {
61
71
  const r = validateTopicPath("pages/randomstuff/foo.md");
62
72
  assert.equal(r.ok, false);
63
73
  assert.match(r.ok === false ? r.reason : "", /not a recognized wiki category/);
74
+ assert.match(r.ok === false ? r.reason : "", /pages\/_meta\/ is reserved for system metadata/);
64
75
  });
65
76
  test("validateTopicPath rejects non-slug topic or page names", () => {
66
77
  const r = validateTopicPath("pages/projects/Chapter House/index.md");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
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"