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.
- package/README.md +2 -69
- package/dist/api/server.js +8 -155
- package/dist/api/server.test.js +1 -1
- package/dist/cli.js +0 -30
- package/dist/config.js +11 -3
- package/dist/copilot/agent-event-bus.js +41 -0
- package/dist/copilot/agent-event-bus.test.js +23 -0
- package/dist/copilot/agents.js +4 -59
- package/dist/copilot/orchestrator.js +20 -39
- package/dist/copilot/orchestrator.test.js +73 -158
- package/dist/copilot/system-message.js +7 -0
- package/dist/copilot/task-event-log.js +5 -5
- package/dist/copilot/task-event-log.test.js +68 -142
- package/dist/copilot/tools.js +72 -132
- package/dist/daemon.js +6 -22
- package/dist/store/db.js +2 -50
- package/dist/store/db.test.js +0 -45
- package/dist/wiki/fs.js +5 -0
- package/dist/wiki/index-manager.js +92 -17
- package/dist/wiki/index-manager.test.js +19 -0
- package/dist/wiki/migrate-topics.js +132 -0
- package/dist/wiki/migrate-topics.test.js +57 -0
- package/dist/wiki/topic-structure.js +167 -0
- package/dist/wiki/topic-structure.test.js +74 -0
- package/package.json +1 -3
- package/web/dist/assets/index-BlIWCM11.js +217 -0
- package/web/dist/assets/index-BlIWCM11.js.map +1 -0
- package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
- package/web/dist/index.html +2 -2
- package/dist/api/ralph.js +0 -153
- package/dist/api/ralph.test.js +0 -101
- package/dist/copilot/agents.squad.test.js +0 -72
- package/dist/copilot/hooks.js +0 -157
- package/dist/copilot/hooks.test.js +0 -315
- package/dist/copilot/squad-event-bus.js +0 -27
- package/dist/copilot/tools.squad.test.js +0 -168
- package/dist/squad/charter.js +0 -125
- package/dist/squad/charter.test.js +0 -89
- package/dist/squad/context.js +0 -48
- package/dist/squad/context.test.js +0 -59
- package/dist/squad/discovery.js +0 -268
- package/dist/squad/discovery.test.js +0 -154
- package/dist/squad/index.js +0 -9
- package/dist/squad/init-cli.js +0 -109
- package/dist/squad/init.js +0 -395
- package/dist/squad/init.test.js +0 -351
- package/dist/squad/mirror.js +0 -83
- package/dist/squad/mirror.scheduler.js +0 -80
- package/dist/squad/mirror.scheduler.test.js +0 -197
- package/dist/squad/mirror.test.js +0 -172
- package/dist/squad/registry.js +0 -162
- package/dist/squad/registry.test.js +0 -31
- package/dist/squad/squad-coordinator-system-message.test.js +0 -190
- package/dist/squad/squad-session-routing.test.js +0 -260
- package/dist/squad/types.js +0 -4
- package/dist/squad/worktree.js +0 -295
- package/dist/squad/worktree.test.js +0 -189
- package/dist/store/squad-sessions.test.js +0 -341
- package/web/dist/assets/index-IgSOXx_a.js +0 -219
- 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'
|
|
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
|
|
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();
|
package/dist/store/db.test.js
CHANGED
|
@@ -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
|
|
43
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
221
|
+
const cat = categoryOfPath(entry.path);
|
|
222
|
+
const list = byCategory.get(cat) || [];
|
|
188
223
|
list.push(entry);
|
|
189
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 (
|
|
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
|