chapterhouse 0.8.1 → 0.8.2

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.
@@ -16,8 +16,9 @@ import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
16
16
  import { assertAgentEditAccess } from "./agent-edit-access.js";
17
17
  import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
18
18
  import { createTeamRouter } from "./team.js";
19
- import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, } from "../wiki/fs.js";
19
+ import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, getWikiDir, } from "../wiki/fs.js";
20
20
  import { parseWikiFrontmatter } from "../wiki/frontmatter.js";
21
+ import { normalizeWikiPath } from "../wiki/path-utils.js";
21
22
  import { loadRegistry, saveRegistry } from "../wiki/project-registry.js";
22
23
  import { getProjectRulesPath, listTopLevelSoftRules, loadProjectRules, loadProjectRuleSummary, renderInitialProjectRulesPage, saveProjectRulesHardFields, saveProjectRulesSoftRules, } from "../wiki/project-rules.js";
23
24
  import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
@@ -192,6 +193,78 @@ function createProjectDetailPayload(slug, cwd) {
192
193
  softRules: listTopLevelSoftRules(rules.soft),
193
194
  };
194
195
  }
196
+ function coerceWikiPageUpdated(path, updated) {
197
+ const normalized = updated?.trim();
198
+ if (normalized) {
199
+ return normalized;
200
+ }
201
+ try {
202
+ return statSync(join(getWikiDir(), path)).mtime.toISOString();
203
+ }
204
+ catch {
205
+ return "unknown";
206
+ }
207
+ }
208
+ function parseWikiSourcePages(value) {
209
+ if (!value) {
210
+ return [];
211
+ }
212
+ try {
213
+ const parsed = JSON.parse(value);
214
+ return Array.isArray(parsed)
215
+ ? parsed.filter((entry) => typeof entry === "string").map((entry) => normalizeWikiPath(entry))
216
+ : [];
217
+ }
218
+ catch {
219
+ return [];
220
+ }
221
+ }
222
+ function listWikiSources(page) {
223
+ const rows = getDb().prepare(`
224
+ SELECT source_type, origin, raw_path, pages_updated, status, session_id, ingested_at
225
+ FROM wiki_sources
226
+ ORDER BY ingested_at DESC, id ASC
227
+ `).all();
228
+ return rows
229
+ .filter((row) => !page || parseWikiSourcePages(row.pages_updated).includes(page))
230
+ .map((row) => ({
231
+ source_url: row.origin,
232
+ path: row.raw_path ?? undefined,
233
+ kind: row.source_type,
234
+ status: row.status,
235
+ session_id: row.session_id,
236
+ ingested_at: row.ingested_at,
237
+ }));
238
+ }
239
+ function wikiPathToSlug(path) {
240
+ const segments = normalizeWikiPath(path).split("/").filter(Boolean);
241
+ const file = segments[segments.length - 1] ?? path;
242
+ const base = file.endsWith(".md") ? file.slice(0, -3) : file;
243
+ if (base === "index" && segments.length >= 2) {
244
+ return segments[segments.length - 2] ?? base;
245
+ }
246
+ return base;
247
+ }
248
+ function listWikiLinks(page) {
249
+ const rows = page
250
+ ? getDb().prepare(`
251
+ SELECT from_page, to_page, link_type
252
+ FROM wiki_links
253
+ WHERE from_page = ? OR to_page = ?
254
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
255
+ `).all(page, page)
256
+ : getDb().prepare(`
257
+ SELECT from_page, to_page, link_type
258
+ FROM wiki_links
259
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
260
+ `).all();
261
+ return rows.map((row) => ({
262
+ source_slug: wikiPathToSlug(row.from_page),
263
+ target_slug: wikiPathToSlug(row.to_page),
264
+ link_type: row.link_type,
265
+ ...(page ? { direction: row.from_page === page ? "outgoing" : "incoming" } : {}),
266
+ }));
267
+ }
195
268
  // Load a configured API token when present; startup validation below enforces auth.
196
269
  let apiToken = null;
197
270
  try {
@@ -312,6 +385,13 @@ function readPathParam(req) {
312
385
  }
313
386
  return raw;
314
387
  }
388
+ function readOptionalPageFilter(req) {
389
+ const raw = req.query.page;
390
+ if (typeof raw !== "string" || !raw.trim()) {
391
+ return undefined;
392
+ }
393
+ return normalizeWikiPath(raw.trim());
394
+ }
315
395
  function assertValidPagePath(path) {
316
396
  try {
317
397
  assertPagePath(path);
@@ -1448,7 +1528,7 @@ app.get("/api/wiki/pages", async (req, res) => {
1448
1528
  summary: e.summary,
1449
1529
  section: e.section,
1450
1530
  tags: e.tags || [],
1451
- updated: e.updated || "",
1531
+ updated: coerceWikiPageUpdated(e.path, e.updated),
1452
1532
  scope: getWikiPageScope(e.path),
1453
1533
  }));
1454
1534
  const orphanResults = listPages()
@@ -1459,11 +1539,19 @@ app.get("/api/wiki/pages", async (req, res) => {
1459
1539
  summary: "",
1460
1540
  section: "Unindexed",
1461
1541
  tags: [],
1462
- updated: "",
1542
+ updated: coerceWikiPageUpdated(p, undefined),
1463
1543
  scope: getWikiPageScope(p),
1464
1544
  }));
1465
1545
  res.json([...indexedResults, ...orphanResults]);
1466
1546
  });
1547
+ app.get("/api/wiki/sources", async (req, res) => {
1548
+ ensureWikiStructure();
1549
+ res.json({ sources: listWikiSources(readOptionalPageFilter(req)) });
1550
+ });
1551
+ app.get("/api/wiki/links", async (req, res) => {
1552
+ ensureWikiStructure();
1553
+ res.json({ links: listWikiLinks(readOptionalPageFilter(req)) });
1554
+ });
1467
1555
  app.get("/api/wiki/page", async (req, res) => {
1468
1556
  const path = assertValidPagePath(readPathParam(req));
1469
1557
  const authorizationHeader = typeof req.headers.authorization === "string"
@@ -1,9 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
6
  import Database from "better-sqlite3";
7
+ import { z } from "zod";
7
8
  import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
8
9
  test("supports API_TOKEN env var and personal health route helpers", async () => {
9
10
  const runtime = await import("./server-runtime.js");
@@ -83,6 +84,23 @@ test("formats named SSE status events", async () => {
83
84
  assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
84
85
  });
85
86
  const repoRoot = process.cwd();
87
+ const wikiLinkResponseSchema = z.object({
88
+ links: z.array(z.object({
89
+ source_slug: z.string(),
90
+ target_slug: z.string(),
91
+ link_type: z.string(),
92
+ }).passthrough()),
93
+ });
94
+ const wikiSourcesResponseSchema = z.object({
95
+ sources: z.array(z.object({
96
+ source_url: z.string().optional(),
97
+ path: z.string().optional(),
98
+ kind: z.string(),
99
+ status: z.string(),
100
+ session_id: z.string().nullable().optional(),
101
+ ingested_at: z.string().nullable().optional(),
102
+ }).passthrough()),
103
+ });
86
104
  function getProjectDbPath(testRoot) {
87
105
  return join(testRoot, ".chapterhouse", "chapterhouse.db");
88
106
  }
@@ -873,6 +891,60 @@ test("server wiki route still returns 404 for other missing wiki pages", async (
873
891
  assert.deepEqual(await response.json(), { error: "Page not found" });
874
892
  });
875
893
  });
894
+ test("GET /api/wiki/pages coerces empty updated fields to page mtimes", async () => {
895
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
896
+ const indexedPath = join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "coerced-updated", "index.md");
897
+ const indexedMtime = new Date("2026-05-15T12:00:00.000Z");
898
+ mkdirSync(join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "coerced-updated"), { recursive: true });
899
+ writeFileSync(indexedPath, "# Coerced Updated\n", "utf-8");
900
+ utimesSync(indexedPath, indexedMtime, indexedMtime);
901
+ const orphanPath = join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "orphan-updated", "index.md");
902
+ const orphanMtime = new Date("2026-05-15T13:30:00.000Z");
903
+ mkdirSync(join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "orphan-updated"), { recursive: true });
904
+ writeFileSync(orphanPath, "# Orphan Updated\n", "utf-8");
905
+ utimesSync(orphanPath, orphanMtime, orphanMtime);
906
+ const db = new Database(getProjectDbPath(testRoot));
907
+ try {
908
+ db.prepare(`
909
+ INSERT INTO wiki_pages (path, title, tags, summary, last_updated)
910
+ VALUES (?, ?, ?, ?, ?)
911
+ `).run("pages/topics/coerced-updated/index.md", "Coerced Updated", "[]", "Missing updated value", "");
912
+ }
913
+ finally {
914
+ db.close();
915
+ }
916
+ const response = await fetch(`${baseUrl}/api/wiki/pages`, {
917
+ headers: { authorization: authHeader },
918
+ });
919
+ assert.equal(response.status, 200);
920
+ const pages = await response.json();
921
+ assert.equal(pages.find((page) => page.path === "pages/topics/coerced-updated/index.md")?.updated, indexedMtime.toISOString());
922
+ assert.equal(pages.find((page) => page.path === "pages/topics/orphan-updated/index.md")?.updated, orphanMtime.toISOString());
923
+ });
924
+ });
925
+ test("GET /api/wiki/pages returns a non-empty updated value for pages with frontmatter", async () => {
926
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
927
+ const pageDir = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "frontmatter-updated");
928
+ mkdirSync(pageDir, { recursive: true });
929
+ writeFileSync(join(pageDir, "index.md"), `---
930
+ title: Frontmatter Updated
931
+ summary: Has explicit metadata.
932
+ updated: 2026-05-14
933
+ ---
934
+
935
+ # Frontmatter Updated
936
+ `, "utf-8");
937
+ const response = await fetch(`${baseUrl}/api/wiki/pages`, {
938
+ headers: { authorization: authHeader },
939
+ });
940
+ assert.equal(response.status, 200);
941
+ const pages = await response.json();
942
+ const page = pages.find((entry) => entry.path === "pages/projects/frontmatter-updated/index.md");
943
+ assert.ok(page, "expected frontmatter page to be listed");
944
+ assert.equal(typeof page.updated, "string");
945
+ assert.notEqual(page.updated, "");
946
+ });
947
+ });
876
948
  test("server projects route returns an empty list when the registry is missing", async () => {
877
949
  await withStartedServer(async ({ baseUrl, authHeader }) => {
878
950
  const response = await fetch(`${baseUrl}/api/projects`, {
@@ -1476,4 +1548,153 @@ test("GET /api/wiki/korg/sessions returns grouped active research sessions", asy
1476
1548
  });
1477
1549
  });
1478
1550
  });
1551
+ test("GET /api/wiki/sources returns an empty sources payload when no sources exist", async () => {
1552
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1553
+ const response = await fetch(`${baseUrl}/api/wiki/sources`, {
1554
+ headers: { authorization: authHeader },
1555
+ });
1556
+ assert.equal(response.status, 200);
1557
+ const body = await response.json();
1558
+ assert.deepEqual(body, { sources: [] });
1559
+ assert.ok(wikiSourcesResponseSchema.safeParse(body).success, "response should match WikiSourcesResponseSchema");
1560
+ });
1561
+ });
1562
+ test("GET /api/wiki/sources filters sources by page", async () => {
1563
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1564
+ const db = new Database(getProjectDbPath(testRoot));
1565
+ try {
1566
+ db.prepare(`
1567
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1568
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1569
+ `).run("src-filter-match", "pdf", "https://example.com/roadmap.pdf", "Roadmap", "2026-05-15T10:00:00.000Z", "sources/roadmap.pdf", "roadmap body", JSON.stringify(["pages/projects/wiki-api/index.md"]), "complete", "sess-1", "Wiki API");
1570
+ db.prepare(`
1571
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1572
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1573
+ `).run("src-filter-miss", "text", "notes about another page", "Other", "2026-05-15T11:00:00.000Z", "sources/other.md", "other body", JSON.stringify(["pages/projects/other/index.md"]), "active", null, null);
1574
+ }
1575
+ finally {
1576
+ db.close();
1577
+ }
1578
+ const response = await fetch(`${baseUrl}/api/wiki/sources?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
1579
+ headers: { authorization: authHeader },
1580
+ });
1581
+ assert.equal(response.status, 200);
1582
+ const body = await response.json();
1583
+ const parsed = wikiSourcesResponseSchema.safeParse(body);
1584
+ if (!parsed.success) {
1585
+ assert.fail(parsed.error.message);
1586
+ }
1587
+ assert.deepEqual(parsed.data, {
1588
+ sources: [
1589
+ {
1590
+ source_url: "https://example.com/roadmap.pdf",
1591
+ path: "sources/roadmap.pdf",
1592
+ kind: "pdf",
1593
+ status: "complete",
1594
+ session_id: "sess-1",
1595
+ ingested_at: "2026-05-15T10:00:00.000Z",
1596
+ },
1597
+ ],
1598
+ });
1599
+ });
1600
+ });
1601
+ test("GET /api/wiki/links returns all links when no page filter is provided", async () => {
1602
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1603
+ const db = new Database(getProjectDbPath(testRoot));
1604
+ try {
1605
+ db.prepare(`
1606
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1607
+ VALUES (?, ?, ?, ?)
1608
+ `).run("pages/projects/wiki-api/index.md", "pages/topics/contracts/index.md", "references", "2026-05-15T12:00:00.000Z");
1609
+ db.prepare(`
1610
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1611
+ VALUES (?, ?, ?, ?)
1612
+ `).run("pages/people/trinity/index.md", "pages/projects/wiki-api/index.md", "implements", "2026-05-15T12:01:00.000Z");
1613
+ }
1614
+ finally {
1615
+ db.close();
1616
+ }
1617
+ const response = await fetch(`${baseUrl}/api/wiki/links`, {
1618
+ headers: { authorization: authHeader },
1619
+ });
1620
+ assert.equal(response.status, 200);
1621
+ const body = await response.json();
1622
+ const parsed = wikiLinkResponseSchema.safeParse(body);
1623
+ if (!parsed.success) {
1624
+ assert.fail(parsed.error.message);
1625
+ }
1626
+ assert.deepEqual(parsed.data, {
1627
+ links: [
1628
+ {
1629
+ source_slug: "trinity",
1630
+ target_slug: "wiki-api",
1631
+ link_type: "implements",
1632
+ },
1633
+ {
1634
+ source_slug: "wiki-api",
1635
+ target_slug: "contracts",
1636
+ link_type: "references",
1637
+ },
1638
+ ],
1639
+ });
1640
+ });
1641
+ });
1642
+ test("GET /api/wiki/links returns an empty typed links payload when a page has no graph edges", async () => {
1643
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1644
+ const response = await fetch(`${baseUrl}/api/wiki/links?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
1645
+ headers: { authorization: authHeader },
1646
+ });
1647
+ assert.equal(response.status, 200);
1648
+ const body = await response.json();
1649
+ assert.deepEqual(body, { links: [] });
1650
+ assert.ok(wikiLinkResponseSchema.safeParse(body).success, "response should match WikiLinksResponseSchema");
1651
+ });
1652
+ });
1653
+ test("GET /api/wiki/links filters incoming and outgoing links for a page", async () => {
1654
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1655
+ const db = new Database(getProjectDbPath(testRoot));
1656
+ try {
1657
+ db.prepare(`
1658
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1659
+ VALUES (?, ?, ?, ?)
1660
+ `).run("pages/projects/wiki-api/index.md", "pages/topics/contracts/index.md", "references", "2026-05-15T12:00:00.000Z");
1661
+ db.prepare(`
1662
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1663
+ VALUES (?, ?, ?, ?)
1664
+ `).run("pages/people/trinity/index.md", "pages/projects/wiki-api/index.md", "implements", "2026-05-15T12:01:00.000Z");
1665
+ db.prepare(`
1666
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1667
+ VALUES (?, ?, ?, ?)
1668
+ `).run("pages/projects/other/index.md", "pages/topics/unrelated/index.md", "references", "2026-05-15T12:02:00.000Z");
1669
+ }
1670
+ finally {
1671
+ db.close();
1672
+ }
1673
+ const response = await fetch(`${baseUrl}/api/wiki/links?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
1674
+ headers: { authorization: authHeader },
1675
+ });
1676
+ assert.equal(response.status, 200);
1677
+ const body = await response.json();
1678
+ const parsed = wikiLinkResponseSchema.safeParse(body);
1679
+ if (!parsed.success) {
1680
+ assert.fail(parsed.error.message);
1681
+ }
1682
+ assert.deepEqual(parsed.data, {
1683
+ links: [
1684
+ {
1685
+ source_slug: "trinity",
1686
+ target_slug: "wiki-api",
1687
+ link_type: "implements",
1688
+ direction: "incoming",
1689
+ },
1690
+ {
1691
+ source_slug: "wiki-api",
1692
+ target_slug: "contracts",
1693
+ link_type: "references",
1694
+ direction: "outgoing",
1695
+ },
1696
+ ],
1697
+ });
1698
+ });
1699
+ });
1479
1700
  //# sourceMappingURL=server.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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"