chapterhouse 0.3.13 → 0.3.15

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.
Files changed (60) hide show
  1. package/README.md +2 -69
  2. package/dist/api/server.js +8 -155
  3. package/dist/api/server.test.js +1 -1
  4. package/dist/cli.js +0 -30
  5. package/dist/config.js +11 -3
  6. package/dist/copilot/agent-event-bus.js +41 -0
  7. package/dist/copilot/agent-event-bus.test.js +23 -0
  8. package/dist/copilot/agents.js +4 -59
  9. package/dist/copilot/orchestrator.js +20 -39
  10. package/dist/copilot/orchestrator.test.js +73 -158
  11. package/dist/copilot/system-message.js +7 -0
  12. package/dist/copilot/task-event-log.js +5 -5
  13. package/dist/copilot/task-event-log.test.js +68 -142
  14. package/dist/copilot/tools.js +72 -132
  15. package/dist/daemon.js +6 -22
  16. package/dist/store/db.js +2 -50
  17. package/dist/store/db.test.js +0 -45
  18. package/dist/wiki/fs.js +5 -0
  19. package/dist/wiki/index-manager.js +92 -17
  20. package/dist/wiki/index-manager.test.js +19 -0
  21. package/dist/wiki/migrate-topics.js +132 -0
  22. package/dist/wiki/migrate-topics.test.js +57 -0
  23. package/dist/wiki/topic-structure.js +167 -0
  24. package/dist/wiki/topic-structure.test.js +74 -0
  25. package/package.json +1 -3
  26. package/web/dist/assets/index-BlIWCM11.js +217 -0
  27. package/web/dist/assets/index-BlIWCM11.js.map +1 -0
  28. package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
  29. package/web/dist/index.html +2 -2
  30. package/dist/api/ralph.js +0 -153
  31. package/dist/api/ralph.test.js +0 -101
  32. package/dist/copilot/agents.squad.test.js +0 -72
  33. package/dist/copilot/hooks.js +0 -157
  34. package/dist/copilot/hooks.test.js +0 -315
  35. package/dist/copilot/squad-event-bus.js +0 -27
  36. package/dist/copilot/tools.squad.test.js +0 -168
  37. package/dist/squad/charter.js +0 -125
  38. package/dist/squad/charter.test.js +0 -89
  39. package/dist/squad/context.js +0 -48
  40. package/dist/squad/context.test.js +0 -59
  41. package/dist/squad/discovery.js +0 -268
  42. package/dist/squad/discovery.test.js +0 -154
  43. package/dist/squad/index.js +0 -9
  44. package/dist/squad/init-cli.js +0 -109
  45. package/dist/squad/init.js +0 -395
  46. package/dist/squad/init.test.js +0 -351
  47. package/dist/squad/mirror.js +0 -83
  48. package/dist/squad/mirror.scheduler.js +0 -80
  49. package/dist/squad/mirror.scheduler.test.js +0 -197
  50. package/dist/squad/mirror.test.js +0 -172
  51. package/dist/squad/registry.js +0 -162
  52. package/dist/squad/registry.test.js +0 -31
  53. package/dist/squad/squad-coordinator-system-message.test.js +0 -190
  54. package/dist/squad/squad-session-routing.test.js +0 -260
  55. package/dist/squad/types.js +0 -4
  56. package/dist/squad/worktree.js +0 -295
  57. package/dist/squad/worktree.test.js +0 -189
  58. package/dist/store/squad-sessions.test.js +0 -341
  59. package/web/dist/assets/index-IgSOXx_a.js +0 -219
  60. package/web/dist/assets/index-IgSOXx_a.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -2,7 +2,6 @@ import { getClient, stopClient } from "./copilot/client.js";
2
2
  import { initOrchestrator, setMessageLogger, setProactiveNotify, getAgentInfo, shutdownAgents } from "./copilot/orchestrator.js";
3
3
  import { stopEpisodeWriter } from "./copilot/episode-writer.js";
4
4
  import { startApiServer, broadcastToSSE } from "./api/server.js";
5
- import { killRalphOnShutdown } from "./api/ralph.js";
6
5
  import { getDb, closeDb, getState } from "./store/db.js";
7
6
  import { config } from "./config.js";
8
7
  import { spawn } from "child_process";
@@ -12,10 +11,10 @@ import { checkForUpdate } from "./update.js";
12
11
  import { ensureWikiStructure } from "./wiki/fs.js";
13
12
  import { seedTeamWiki } from "./wiki/seed-team-wiki.js";
14
13
  import { shouldMigrate, migrateMemoriesToWiki, shouldReorganize, reorganizeWiki } from "./wiki/migrate.js";
14
+ import { shouldEnforceTopics, enforceTopicStructure } from "./wiki/migrate-topics.js";
15
15
  import { SESSIONS_DIR } from "./paths.js";
16
16
  import { getDisplayHost } from "./api/server-runtime.js";
17
17
  import { StandupScheduler } from "./copilot/standup.js";
18
- import { DecisionsSyncScheduler } from "./squad/mirror.scheduler.js";
19
18
  import { registerShutdownSignals } from "./shutdown-signals.js";
20
19
  import { logger } from "./util/logger.js";
21
20
  import { CHAPTERHOUSE_VERSION } from "./version.js";
@@ -115,6 +114,11 @@ async function main() {
115
114
  const count = reorganizeWiki();
116
115
  log.info({ count }, "Created entity pages during reorganization");
117
116
  }
117
+ if (shouldEnforceTopics()) {
118
+ log.info("Enforcing wiki topic-directory structure");
119
+ const moved = enforceTopicStructure();
120
+ log.info({ moved }, "Topic-structure migration complete");
121
+ }
118
122
  // Prune orphaned session folders older than 7 days
119
123
  pruneOldSessions();
120
124
  // One-time deprecation note for legacy Telegram users (v1 → v2)
@@ -148,9 +152,6 @@ async function main() {
148
152
  if (config.chapterhouseMode === "personal" && (config.adoPat || config.teamChapterhouseUrl)) {
149
153
  new StandupScheduler().schedule();
150
154
  }
151
- // Start periodic decisions→wiki sync for all registered projects
152
- decisionsSyncScheduler = new DecisionsSyncScheduler();
153
- decisionsSyncScheduler.start();
154
155
  const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
155
156
  log.info({ url }, "Chapterhouse is fully operational");
156
157
  if (process.env.CHAPTERHOUSE_OPEN_BROWSER === "1") {
@@ -177,7 +178,6 @@ async function main() {
177
178
  }
178
179
  // Graceful shutdown
179
180
  let shutdownState = "idle";
180
- let decisionsSyncScheduler;
181
181
  async function shutdown() {
182
182
  if (shutdownState === "shutting_down") {
183
183
  log.warn("Forced exit");
@@ -202,10 +202,6 @@ async function shutdown() {
202
202
  forceTimer.unref();
203
203
  // Destroy all active agent sessions
204
204
  await shutdownAgents();
205
- try {
206
- decisionsSyncScheduler?.stop();
207
- }
208
- catch { /* best effort */ }
209
205
  try {
210
206
  stopEpisodeWriter();
211
207
  }
@@ -214,10 +210,6 @@ async function shutdown() {
214
210
  await stopClient();
215
211
  }
216
212
  catch { /* best effort */ }
217
- try {
218
- killRalphOnShutdown();
219
- }
220
- catch { /* best effort */ }
221
213
  closeDb();
222
214
  log.info("Goodbye");
223
215
  process.exit(0);
@@ -231,10 +223,6 @@ export async function restartDaemon() {
231
223
  }
232
224
  // Destroy all active agent sessions
233
225
  await shutdownAgents();
234
- try {
235
- decisionsSyncScheduler?.stop();
236
- }
237
- catch { /* best effort */ }
238
226
  try {
239
227
  stopEpisodeWriter();
240
228
  }
@@ -243,10 +231,6 @@ export async function restartDaemon() {
243
231
  await stopClient();
244
232
  }
245
233
  catch { /* best effort */ }
246
- try {
247
- killRalphOnShutdown();
248
- }
249
- catch { /* best effort */ }
250
234
  closeDb();
251
235
  // Spawn a detached replacement process with the same args (include execArgv for tsx/loaders)
252
236
  const child = spawn(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {
package/dist/store/db.js CHANGED
@@ -68,42 +68,6 @@ export function getDb() {
68
68
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
69
69
  last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP
70
70
  )
71
- `);
72
- db.exec(`
73
- CREATE TABLE IF NOT EXISTS project_squads (
74
- project_root TEXT PRIMARY KEY,
75
- squad_dir TEXT NOT NULL,
76
- team_dir TEXT NOT NULL,
77
- personal_dir TEXT,
78
- mode TEXT NOT NULL CHECK(mode IN ('local', 'remote')),
79
- project_key TEXT,
80
- config_source TEXT,
81
- registered INTEGER NOT NULL DEFAULT 0,
82
- loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
83
- )
84
- `);
85
- db.exec(`
86
- CREATE TABLE IF NOT EXISTS squad_agents (
87
- project_root TEXT NOT NULL,
88
- slug TEXT NOT NULL,
89
- role TEXT NOT NULL,
90
- description TEXT,
91
- charter_path TEXT NOT NULL,
92
- model_preference TEXT,
93
- tools_json TEXT,
94
- capabilities_json TEXT,
95
- stale INTEGER NOT NULL DEFAULT 0,
96
- loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
97
- PRIMARY KEY (project_root, slug)
98
- )
99
- `);
100
- db.exec(`
101
- CREATE TABLE IF NOT EXISTS squad_task_links (
102
- task_id TEXT PRIMARY KEY,
103
- project_root TEXT NOT NULL,
104
- squad_agent_slug TEXT NOT NULL,
105
- wiki_decision_path TEXT NOT NULL
106
- )
107
71
  `);
108
72
  // Migrate: if the table already existed with a stricter CHECK, recreate it
109
73
  try {
@@ -146,7 +110,7 @@ export function getDb() {
146
110
  if (!taskCols.some((c) => c.name === 'session_key')) {
147
111
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN session_key TEXT NOT NULL DEFAULT 'default'`);
148
112
  }
149
- // Migrate: add source column to agent_tasks ('adhoc' | 'squad') if not present
113
+ // Migrate: add source column to agent_tasks ('adhoc') if not present
150
114
  if (!taskCols.some((c) => c.name === 'source')) {
151
115
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
152
116
  }
@@ -168,13 +132,6 @@ export function getDb() {
168
132
  if (!taskColsNow.some((c) => c.name === 'event_seq')) {
169
133
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN event_seq INTEGER NOT NULL DEFAULT 0`);
170
134
  }
171
- // Migrate: add last_used_at column to project_squads (epoch ms, nullable)
172
- const projectCols = db.prepare(`PRAGMA table_info(project_squads)`).all();
173
- if (!projectCols.some((c) => c.name === 'last_used_at')) {
174
- db.exec(`ALTER TABLE project_squads ADD COLUMN last_used_at INTEGER`);
175
- // Backfill from loaded_at so sidebar is not empty for existing projects
176
- db.exec(`UPDATE project_squads SET last_used_at = CAST((julianday(loaded_at) - 2440587.5) * 86400000 AS INTEGER) WHERE last_used_at IS NULL`);
177
- }
178
135
  // turn_events: append-only per-turn event log for the SSE chat channel (#130).
179
136
  // Events are written on turn completion; ring buffer serves live/recent replay.
180
137
  db.exec(`
@@ -297,7 +254,7 @@ export function getTaskSessionKey(taskId) {
297
254
  * Get recent conversation history formatted for injection into system message.
298
255
  *
299
256
  * When `sessionKey` is provided, only rows belonging to that session are
300
- * returned — useful for project/squad sessions that must not bleed context
257
+ * returned — useful for per-session isolation that must not bleed context
301
258
  * from other sessions. When omitted, rows from all sessions are returned
302
259
  * (legacy behavior for callers that don't care about session isolation).
303
260
  */
@@ -408,11 +365,6 @@ export function getTaskEvents(taskId, afterSeq = 0) {
408
365
  summary: r.summary,
409
366
  }));
410
367
  }
411
- export function bumpProjectLastUsed(projectRoot) {
412
- getDb()
413
- .prepare(`UPDATE project_squads SET last_used_at = ? WHERE project_root = ?`)
414
- .run(Date.now(), projectRoot);
415
- }
416
368
  export function closeDb() {
417
369
  if (db) {
418
370
  db.close();
@@ -167,51 +167,6 @@ test("getSessionMessages returns structured messages in chronological order, exc
167
167
  }
168
168
  });
169
169
  // ---------------------------------------------------------------------------
170
- // #26 — bumpProjectLastUsed
171
- // ---------------------------------------------------------------------------
172
- test("migration adds last_used_at column to project_squads when absent", async () => {
173
- const dbModule = await loadDbModule();
174
- try {
175
- const db = dbModule.getDb();
176
- const cols = db.prepare(`PRAGMA table_info(project_squads)`).all();
177
- assert.equal(cols.some((c) => c.name === "last_used_at"), true, "last_used_at column should exist after migration");
178
- }
179
- finally {
180
- dbModule.closeDb();
181
- }
182
- });
183
- test("bumpProjectLastUsed updates last_used_at for the given project_root", async () => {
184
- const dbModule = await loadDbModule();
185
- try {
186
- const db = dbModule.getDb();
187
- db.prepare(`INSERT INTO project_squads (project_root, squad_dir, team_dir, mode, registered) VALUES (?, ?, ?, 'local', 1)`).run("/home/user/test-proj", "/home/user/test-proj/.squad", "/home/user/test-proj/.squad");
188
- const before = db
189
- .prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
190
- .get("/home/user/test-proj");
191
- const beforeTs = before.last_used_at ?? 0;
192
- await new Promise((r) => setTimeout(r, 5));
193
- dbModule.bumpProjectLastUsed("/home/user/test-proj");
194
- const after = db
195
- .prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
196
- .get("/home/user/test-proj");
197
- assert.ok(after.last_used_at !== null, "last_used_at should not be null after bump");
198
- assert.ok(after.last_used_at > beforeTs, "last_used_at should advance after bump");
199
- }
200
- finally {
201
- dbModule.closeDb();
202
- }
203
- });
204
- test("bumpProjectLastUsed is a no-op for unknown project_root (no throw)", async () => {
205
- const dbModule = await loadDbModule();
206
- try {
207
- dbModule.getDb();
208
- dbModule.bumpProjectLastUsed("/does/not/exist");
209
- }
210
- finally {
211
- dbModule.closeDb();
212
- }
213
- });
214
- // ---------------------------------------------------------------------------
215
170
  // #86: agent_task_events — appendTaskEvent and getTaskEvents
216
171
  // ---------------------------------------------------------------------------
217
172
  test("#86: appendTaskEvent inserts a row and getTaskEvents returns it ordered by seq", async () => {
package/dist/wiki/fs.js CHANGED
@@ -5,6 +5,7 @@ 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
10
  const LOG_PATH = join(WIKI_DIR, "log.md");
10
11
  /**
@@ -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
  }
@@ -6,6 +6,7 @@ import { join } from "path";
6
6
  import { WIKI_DIR } from "../paths.js";
7
7
  import { readIndexFile, writeIndexFile, listPages, readPage } from "./fs.js";
8
8
  import { normalizeWikiPath } from "./path-utils.js";
9
+ import { entityCategories, FLAT_CATEGORIES } from "./topic-structure.js";
9
10
  const INDEX_PATH = join(WIKI_DIR, "index.md");
10
11
  // mtime-based cache so per-message context injection doesn't re-parse on every turn.
11
12
  let cache;
@@ -39,8 +40,9 @@ export function parseIndex() {
39
40
  currentSection = sectionMatch[1].trim();
40
41
  continue;
41
42
  }
42
- // Entry lines: - [Title](path) Summary | tags: t1, t2 | updated: YYYY-MM-DD
43
- const entryMatch = line.match(/^-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/);
43
+ // Entry lines (possibly indented sub-bullets):
44
+ // - [Title](path) — Summary | tags: t1, t2 | updated: YYYY-MM-DD
45
+ const entryMatch = line.match(/^\s*-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/);
44
46
  if (entryMatch) {
45
47
  const rawSummary = entryMatch[3].trim();
46
48
  // Parse optional | tags: ... | updated: ... suffixes
@@ -120,9 +122,11 @@ export function buildIndexEntryForPage(path, fallback) {
120
122
  const body = content.replace(/^---[\s\S]*?---\s*/, "");
121
123
  for (const raw of body.split("\n")) {
122
124
  const line = raw.trim();
123
- if (!line || line.startsWith("#"))
125
+ if (!line || line.startsWith("#") || line.startsWith("<!--"))
124
126
  continue;
125
127
  summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
128
+ if (!summary)
129
+ continue;
126
130
  break;
127
131
  }
128
132
  }
@@ -157,7 +161,7 @@ export function rebuildIndexFromPages() {
157
161
  section = sm[1].trim();
158
162
  continue;
159
163
  }
160
- const em = line.match(/^-\s+\[.+?\]\((.+?)\)/);
164
+ const em = line.match(/^\s*-\s+\[.+?\]\((.+?)\)/);
161
165
  if (em) {
162
166
  const normalizedPath = normalizeWikiPath(em[1].trim());
163
167
  previous.set(normalizedPath, { path: normalizedPath, title: "", summary: "", section });
@@ -181,13 +185,51 @@ export function writeIndex(entries) {
181
185
  writeIndexInternal(entries);
182
186
  invalidateCache();
183
187
  }
188
+ /** Derive the top-level category of a page from its path under pages/. */
189
+ function categoryOfPath(path) {
190
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
191
+ const segs = rest.split("/").filter(Boolean);
192
+ if (segs.length <= 1)
193
+ return (segs[0] || "pages").replace(/\.md$/i, "");
194
+ return segs[0];
195
+ }
196
+ /** Derive the topic slug of an entity-category page (pages/<cat>/<topic>/<file>), if any. */
197
+ function topicOfPath(path) {
198
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
199
+ const segs = rest.split("/").filter(Boolean);
200
+ return segs.length >= 3 ? segs[1] : undefined;
201
+ }
202
+ function isTopicIndexFile(path) {
203
+ return /\/index\.md$/i.test(path);
204
+ }
205
+ function humanize(slug) {
206
+ return slug.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
207
+ }
208
+ function renderEntryLine(item, indent = "") {
209
+ let line = `${indent}- [${item.title}](${item.path}) — ${item.summary}`;
210
+ if (item.tags?.length)
211
+ line += ` | tags: ${item.tags.join(", ")}`;
212
+ if (item.updated)
213
+ line += ` | updated: ${item.updated}`;
214
+ return line;
215
+ }
184
216
  function writeIndexInternal(entries) {
185
- const sections = new Map();
217
+ // Group by top-level category derived from the page path (not the stored
218
+ // `section`, which is no longer authoritative).
219
+ const byCategory = new Map();
186
220
  for (const entry of entries) {
187
- const list = sections.get(entry.section) || [];
221
+ const cat = categoryOfPath(entry.path);
222
+ const list = byCategory.get(cat) || [];
188
223
  list.push(entry);
189
- sections.set(entry.section, list);
224
+ byCategory.set(cat, list);
190
225
  }
226
+ const entityCats = entityCategories();
227
+ const entityCatSet = new Set(entityCats);
228
+ const known = [...entityCats, ...FLAT_CATEGORIES];
229
+ const orderedCategories = [
230
+ ...known.filter((c) => byCategory.has(c)),
231
+ ...[...byCategory.keys()].filter((c) => !known.includes(c)).sort(),
232
+ ];
191
233
  const lines = [
192
234
  "# Wiki Index",
193
235
  "",
@@ -196,19 +238,52 @@ function writeIndexInternal(entries) {
196
238
  `Last updated: ${new Date().toISOString().slice(0, 10)}`,
197
239
  "",
198
240
  ];
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);
241
+ for (const cat of orderedCategories) {
242
+ const items = byCategory.get(cat);
243
+ lines.push(`## ${humanize(cat)}`, "");
244
+ if (entityCatSet.has(cat)) {
245
+ // Two-level layout: topic directory -> overview (index.md) + facet pages.
246
+ // Pages not yet in canonical <category>/<topic>/<file> shape are listed as
247
+ // plain bullets (they'll be relocated by the topic-structure migration).
248
+ const byTopic = new Map();
249
+ const ungrouped = [];
250
+ for (const entry of items) {
251
+ const topic = topicOfPath(entry.path);
252
+ if (!topic) {
253
+ ungrouped.push(entry);
254
+ continue;
255
+ }
256
+ const list = byTopic.get(topic) || [];
257
+ list.push(entry);
258
+ byTopic.set(topic, list);
259
+ }
260
+ for (const topic of [...byTopic.keys()].sort()) {
261
+ const topicItems = byTopic.get(topic);
262
+ const overview = topicItems.find((e) => isTopicIndexFile(e.path));
263
+ const facets = topicItems
264
+ .filter((e) => !isTopicIndexFile(e.path))
265
+ .sort((a, b) => a.path.localeCompare(b.path));
266
+ if (overview) {
267
+ lines.push(renderEntryLine(overview));
268
+ }
269
+ else {
270
+ lines.push(`- **${humanize(topic)}** _(no overview page)_`);
271
+ }
272
+ for (const facet of facets)
273
+ lines.push(renderEntryLine(facet, " "));
274
+ }
275
+ for (const entry of ungrouped.sort((a, b) => a.path.localeCompare(b.path))) {
276
+ lines.push(renderEntryLine(entry));
277
+ }
278
+ }
279
+ else {
280
+ for (const entry of [...items].sort((a, b) => a.path.localeCompare(b.path))) {
281
+ lines.push(renderEntryLine(entry));
282
+ }
208
283
  }
209
284
  lines.push("");
210
285
  }
211
- if (sections.size === 0) {
286
+ if (orderedCategories.length === 0) {
212
287
  lines.push("## Pages", "", "_(No pages yet.)_", "");
213
288
  }
214
289
  writeIndexFile(lines.join("\n"));
@@ -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");
@@ -0,0 +1,132 @@
1
+ // ---------------------------------------------------------------------------
2
+ // One-time migration: enforce the topic-directory structure for entity
3
+ // categories (pages/<category>/<topic-slug>/index.md + facet pages).
4
+ //
5
+ // Fixes wikis created before the structure was enforced, e.g.:
6
+ // pages/projects/chapterhouse.md -> pages/projects/chapterhouse/index.md
7
+ // pages/projects/chapterhouse-feature-ideas.md -> pages/projects/chapterhouse/feature-ideas.md
8
+ // pages/projects/chapterhouse/decisions.md -> (already correct, left alone)
9
+ // ---------------------------------------------------------------------------
10
+ import { existsSync, readdirSync, statSync } from "fs";
11
+ import { join } from "path";
12
+ import { getState, setState } from "../store/db.js";
13
+ import { WIKI_PAGES_DIR } from "../paths.js";
14
+ import { ensureWikiStructure, readPage, writePage, deletePage, listPages } from "./fs.js";
15
+ import { rebuildIndexFromPages } from "./index-manager.js";
16
+ import { appendLog } from "./log-manager.js";
17
+ import { entityCategories, slugify } from "./topic-structure.js";
18
+ import { childLogger } from "../util/logger.js";
19
+ const log = childLogger("wiki:migrate-topics");
20
+ const TOPIC_STRUCTURE_KEY = "wiki_topic_structure_v1";
21
+ export function shouldEnforceTopics() {
22
+ return getState(TOPIC_STRUCTURE_KEY) !== "true";
23
+ }
24
+ function dirNamesUnder(absDir) {
25
+ if (!existsSync(absDir))
26
+ return [];
27
+ return readdirSync(absDir, { withFileTypes: true })
28
+ .filter((e) => e.isDirectory())
29
+ .map((e) => e.name);
30
+ }
31
+ function fileBaseNamesUnder(absDir) {
32
+ if (!existsSync(absDir))
33
+ return [];
34
+ return readdirSync(absDir, { withFileTypes: true })
35
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
36
+ .map((e) => e.name.replace(/\.md$/i, ""));
37
+ }
38
+ function stripFrontmatter(content) {
39
+ return content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
40
+ }
41
+ /** Move src -> dest. If dest exists, merge src's body into it. Returns true if anything happened. */
42
+ function moveOrMerge(srcRel, destRel, today) {
43
+ if (srcRel === destRel)
44
+ return false;
45
+ const srcContent = readPage(srcRel);
46
+ if (srcContent == null)
47
+ return false;
48
+ const destContent = readPage(destRel);
49
+ if (destContent != null) {
50
+ const merged = `${destContent.trimEnd()}\n\n<!-- merged from ${srcRel} on ${today} -->\n\n${stripFrontmatter(srcContent).trim()}\n`;
51
+ writePage(destRel, merged);
52
+ }
53
+ else {
54
+ writePage(destRel, srcContent);
55
+ }
56
+ deletePage(srcRel);
57
+ appendLog("migrate", `topic-structure: ${srcRel} -> ${destRel}${destContent != null ? " (merged)" : ""}`);
58
+ log.info({ from: srcRel, to: destRel, merged: destContent != null }, "moved wiki page into topic structure");
59
+ return true;
60
+ }
61
+ /**
62
+ * Bring the wiki page tree into compliance with the topic structure.
63
+ * Idempotent; safe to call repeatedly. Returns the number of pages moved/merged.
64
+ */
65
+ export function enforceTopicStructure() {
66
+ ensureWikiStructure();
67
+ const today = new Date().toISOString().slice(0, 10);
68
+ let moves = 0;
69
+ for (const cat of entityCategories()) {
70
+ const catAbs = join(WIKI_PAGES_DIR, cat);
71
+ if (!existsSync(catAbs) || !statSync(catAbs).isDirectory()) {
72
+ // Also handle a bare `pages/<cat>.md` flat file for an entity category.
73
+ const bareFlat = `pages/${cat}.md`;
74
+ if (readPage(bareFlat) != null) {
75
+ if (moveOrMerge(bareFlat, `pages/${cat}/general/index.md`, today)) {
76
+ moves++;
77
+ log.warn({ page: bareFlat }, "moved entity-category flat file to a 'general' topic; consider splitting it");
78
+ }
79
+ }
80
+ continue;
81
+ }
82
+ // Topics already represented by a directory under pages/<cat>/.
83
+ const existingDirs = new Set(dirNamesUnder(catAbs));
84
+ // All bare files directly under pages/<cat>/ (the things that need relocating).
85
+ const bareFiles = fileBaseNamesUnder(catAbs);
86
+ // The full set of candidate topic stems: existing dirs + every bare file stem.
87
+ const topicSet = new Set([...existingDirs, ...bareFiles]);
88
+ for (const base of bareFiles) {
89
+ const srcRel = `pages/${cat}/${base}.md`;
90
+ // Is `base` a facet of some other topic T (i.e. base === "T-<facet>")? Pick the longest such T.
91
+ let facetOf;
92
+ let facetName = "";
93
+ for (const t of topicSet) {
94
+ if (t === base)
95
+ continue;
96
+ if (base.startsWith(`${t}-`) && t.length > (facetOf?.length ?? 0)) {
97
+ facetOf = t;
98
+ facetName = base.slice(t.length + 1);
99
+ }
100
+ }
101
+ if (facetOf) {
102
+ const dest = `pages/${cat}/${slugify(facetOf)}/${slugify(facetName) || "notes"}.md`;
103
+ if (moveOrMerge(srcRel, dest, today))
104
+ moves++;
105
+ }
106
+ else {
107
+ const dest = `pages/${cat}/${slugify(base)}/index.md`;
108
+ if (moveOrMerge(srcRel, dest, today))
109
+ moves++;
110
+ }
111
+ }
112
+ }
113
+ if (moves > 0) {
114
+ rebuildIndexFromPages();
115
+ appendLog("migrate", `topic-structure: ${moves} page(s) relocated; index rebuilt`);
116
+ }
117
+ else {
118
+ // Still rebuild once so the index picks up the hierarchical layout.
119
+ rebuildIndexFromPages();
120
+ }
121
+ // Sanity log of anything still off (non-blocking).
122
+ for (const p of listPages()) {
123
+ const segs = p.replace(/^pages\//, "").split("/").filter(Boolean);
124
+ if (entityCategories().includes(segs[0]) && segs.length !== 3) {
125
+ log.warn({ page: p }, "wiki page still not in canonical topic shape after migration");
126
+ }
127
+ }
128
+ setState(TOPIC_STRUCTURE_KEY, "true");
129
+ log.info({ moves }, "topic-structure migration complete");
130
+ return moves;
131
+ }
132
+ //# sourceMappingURL=migrate-topics.js.map
@@ -0,0 +1,57 @@
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-migrate-topics-${process.pid}`);
7
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
8
+ async function loadModules() {
9
+ const nonce = `${Date.now()}-${Math.random()}`;
10
+ const migrate = await import(new URL(`./migrate-topics.js?case=${nonce}`, import.meta.url).href);
11
+ const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
12
+ return { migrate, wikiFs };
13
+ }
14
+ test.beforeEach(() => {
15
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
16
+ rmSync(sandboxRoot, { recursive: true, force: true });
17
+ });
18
+ test.after(() => {
19
+ rmSync(sandboxRoot, { recursive: true, force: true });
20
+ });
21
+ test("enforceTopicStructure relocates bare entity pages and folds facet pages", async () => {
22
+ const { migrate, wikiFs } = await loadModules();
23
+ wikiFs.ensureWikiStructure();
24
+ wikiFs.writePage("pages/projects/chapterhouse.md", "---\ntitle: Chapterhouse\n---\n\n# Chapterhouse\n\n- Source at ~/projects/chapterhouse\n");
25
+ wikiFs.writePage("pages/projects/chapterhouse-feature-ideas.md", "---\ntitle: Chapterhouse Feature Ideas\n---\n\n# Feature Ideas\n\n- Add wiki topics\n");
26
+ wikiFs.writePage("pages/projects/chapterhouse/decisions.md", "---\ntitle: Decisions\n---\n\n# Decisions\n\n- Use SSE\n");
27
+ wikiFs.writePage("pages/conversations/2026-05-09.md", "# 2026-05-09\n\nDaily summary.\n");
28
+ const moves = migrate.enforceTopicStructure();
29
+ assert.equal(moves, 2);
30
+ assert.equal(wikiFs.pageExists("pages/projects/chapterhouse.md"), false);
31
+ assert.equal(wikiFs.pageExists("pages/projects/chapterhouse-feature-ideas.md"), false);
32
+ assert.equal(wikiFs.pageExists("pages/projects/chapterhouse/index.md"), true);
33
+ assert.equal(wikiFs.pageExists("pages/projects/chapterhouse/feature-ideas.md"), true);
34
+ assert.equal(wikiFs.pageExists("pages/projects/chapterhouse/decisions.md"), true);
35
+ // Conversations are exempt and untouched.
36
+ assert.equal(wikiFs.pageExists("pages/conversations/2026-05-09.md"), true);
37
+ assert.match(wikiFs.readPage("pages/projects/chapterhouse/index.md") ?? "", /Source at ~\/projects\/chapterhouse/);
38
+ assert.match(wikiFs.readPage("pages/projects/chapterhouse/feature-ideas.md") ?? "", /Add wiki topics/);
39
+ // Index reflects the new shape.
40
+ assert.match(wikiFs.readIndexFile(), /^- \[Chapterhouse\]\(pages\/projects\/chapterhouse\/index\.md\) — /m);
41
+ assert.match(wikiFs.readIndexFile(), /^ {2}- \[.+\]\(pages\/projects\/chapterhouse\/feature-ideas\.md\) — /m);
42
+ // Idempotent.
43
+ assert.equal(migrate.enforceTopicStructure(), 0);
44
+ });
45
+ test("enforceTopicStructure merges into an existing topic index instead of clobbering it", async () => {
46
+ const { migrate, wikiFs } = await loadModules();
47
+ wikiFs.ensureWikiStructure();
48
+ wikiFs.writePage("pages/people/brian/index.md", "---\ntitle: Brian\n---\n\n# Brian\n\n- Likes Go\n");
49
+ wikiFs.writePage("pages/people/brian.md", "---\ntitle: Brian\n---\n\n# Brian\n\n- Based in the US\n");
50
+ const moves = migrate.enforceTopicStructure();
51
+ assert.equal(moves, 1);
52
+ assert.equal(wikiFs.pageExists("pages/people/brian.md"), false);
53
+ const merged = wikiFs.readPage("pages/people/brian/index.md") ?? "";
54
+ assert.match(merged, /Likes Go/);
55
+ assert.match(merged, /Based in the US/);
56
+ });
57
+ //# sourceMappingURL=migrate-topics.test.js.map