chapterhouse 0.3.18 → 0.3.19

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.
@@ -46,20 +46,4 @@ export function shouldServeSpaPath(pathname) {
46
46
  export function getDisplayHost(host) {
47
47
  return host === "0.0.0.0" || host === "::" || host === "127.0.0.1" || host === "::1" ? "localhost" : host;
48
48
  }
49
- export function buildHistoryEntries(pageIds, options) {
50
- const resolveWikiPath = options?.resolveWikiPath ?? ((pageId) => pageId);
51
- const stat = options?.stat;
52
- return pageIds
53
- .map((pageId) => {
54
- try {
55
- const fullPath = resolveWikiPath(pageId);
56
- const mtime = stat ? stat(fullPath).mtimeMs : 0;
57
- return { path: pageId, mtime };
58
- }
59
- catch {
60
- return { path: pageId, mtime: 0 };
61
- }
62
- })
63
- .sort((a, b) => b.mtime - a.mtime);
64
- }
65
49
  //# sourceMappingURL=server-runtime.js.map
@@ -1,7 +1,7 @@
1
1
  import cors from "cors";
2
2
  import express from "express";
3
3
  import helmet from "helmet";
4
- import { existsSync, statSync } from "fs";
4
+ import { existsSync } from "fs";
5
5
  import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { z } from "zod";
@@ -19,13 +19,13 @@ import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
19
19
  import { withWikiWrite } from "../wiki/lock.js";
20
20
  import { listSkills, removeSkill } from "../copilot/skills.js";
21
21
  import { restartDaemon } from "../daemon.js";
22
- import { API_TOKEN_PATH, resolveWikiRelativePath } from "../paths.js";
22
+ import { API_TOKEN_PATH } from "../paths.js";
23
23
  import { getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
24
24
  import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
25
25
  import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
26
26
  import { getStatus, onStatusChange } from "../status.js";
27
27
  import { formatSseData, formatSseEvent } from "./sse.js";
28
- import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, buildHistoryEntries, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
28
+ import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
29
29
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
30
30
  import { childLogger } from "../util/logger.js";
31
31
  const log = childLogger("server");
@@ -784,17 +784,6 @@ app.delete("/api/wiki/page", async (req, res) => {
784
784
  res.json({ ok: removed, path });
785
785
  });
786
786
  // ---------------------------------------------------------------------------
787
- // History — past conversation summaries auto-written to pages/conversations/
788
- // ---------------------------------------------------------------------------
789
- app.get("/api/history", (_req, res) => {
790
- ensureWikiStructure();
791
- const entries = buildHistoryEntries(listPages().filter((p) => p.startsWith("pages/conversations/")), {
792
- resolveWikiPath: resolveWikiRelativePath,
793
- stat: statSync,
794
- });
795
- res.json(entries);
796
- });
797
- // ---------------------------------------------------------------------------
798
787
  // Skills
799
788
  // ---------------------------------------------------------------------------
800
789
  app.get("/api/skills", (_req, res) => {
@@ -66,31 +66,6 @@ test("formats named SSE status events", async () => {
66
66
  assert.equal(typeof sse.formatSseEvent, "function", "formatSseEvent should be exported");
67
67
  assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
68
68
  });
69
- test("buildHistoryEntries resolves wiki files through the shared path resolver", async () => {
70
- const runtime = await import("./server-runtime.js");
71
- assert.equal(typeof runtime.buildHistoryEntries, "function", "buildHistoryEntries should be exported");
72
- const seen = [];
73
- const entries = runtime.buildHistoryEntries([
74
- "pages/conversations/older.md",
75
- "pages/conversations/newer.md",
76
- ], {
77
- resolveWikiPath: (pageId) => {
78
- seen.push(pageId);
79
- return `/wiki/${pageId}`;
80
- },
81
- stat: (fullPath) => ({
82
- mtimeMs: fullPath.endsWith("newer.md") ? 200 : 100,
83
- }),
84
- });
85
- assert.deepEqual(seen, [
86
- "pages/conversations/older.md",
87
- "pages/conversations/newer.md",
88
- ]);
89
- assert.deepEqual(entries, [
90
- { path: "pages/conversations/newer.md", mtime: 200 },
91
- { path: "pages/conversations/older.md", mtime: 100 },
92
- ]);
93
- });
94
69
  const repoRoot = process.cwd();
95
70
  const serverTestRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}`);
96
71
  async function getFreePort() {
@@ -7,4 +7,8 @@ test("listSkills includes bundled wiki-conventions skill metadata", () => {
7
7
  assert.equal(skill.name, "wiki-conventions");
8
8
  assert.match(skill.description, /creating, editing, linting, restructuring, or reviewing Chapterhouse wiki content/i);
9
9
  });
10
+ test("listSkills does not include a bundled squad skill", () => {
11
+ const skill = listSkills().find((entry) => entry.slug === "squad" && entry.source === "bundled");
12
+ assert.equal(skill, undefined);
13
+ });
10
14
  //# sourceMappingURL=skills.test.js.map
@@ -11,7 +11,9 @@ import { getRouterConfig, updateRouterConfig } from "./router.js";
11
11
  import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
12
12
  import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
13
13
  import { validateWikiFrontmatter } from "../wiki/frontmatter.js";
14
+ import { fixWiki } from "../wiki/fix.js";
14
15
  import { lintWiki, renderWikiLintReport } from "../wiki/lint.js";
16
+ import { rebuildIndexFromPages } from "../wiki/index-manager.js";
15
17
  import { appendLog } from "../wiki/log-manager.js";
16
18
  import { loadTaxonomy } from "../wiki/taxonomy.js";
17
19
  import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
@@ -1088,6 +1090,36 @@ export function createTools(deps) {
1088
1090
  });
1089
1091
  },
1090
1092
  }),
1093
+ defineTool("wiki_fix", {
1094
+ description: "Auto-repair common wiki inconsistencies. Defaults to dry-run preview mode and returns a per-file report " +
1095
+ "plus unified diff. Supports frontmatter backfill, taxonomy tag normalization, and autostub marking.",
1096
+ parameters: z.object({
1097
+ dry_run: z.boolean().optional().describe("Preview only when true (default). Set false to apply fixes."),
1098
+ fixes: z.array(z.enum(["frontmatter-backfill", "tag-normalize", "autostub-mark"])).optional()
1099
+ .describe("Optional subset of fixes to apply. Defaults to all fixes."),
1100
+ path_glob: z.string().optional().describe("Optional glob to limit which wiki page paths are considered."),
1101
+ }),
1102
+ handler: async (args) => {
1103
+ ensureWikiStructure();
1104
+ const dryRun = args.dry_run ?? true;
1105
+ const runFixer = () => {
1106
+ const report = fixWiki({
1107
+ dryRun,
1108
+ fixes: args.fixes,
1109
+ pathGlob: args.path_glob,
1110
+ logAction: dryRun ? undefined : (type, path) => appendLog(type, path),
1111
+ });
1112
+ if (!dryRun && report.changedFiles > 0) {
1113
+ rebuildIndexFromPages();
1114
+ }
1115
+ return report;
1116
+ };
1117
+ if (dryRun) {
1118
+ return runFixer();
1119
+ }
1120
+ return withWikiWrite(runFixer);
1121
+ },
1122
+ }),
1091
1123
  defineTool("restart_chapterhouse", {
1092
1124
  description: "Restart the Chapterhouse daemon process. Use when the user asks Chapterhouse to restart himself, " +
1093
1125
  "or when a restart is needed to pick up configuration changes. " +
@@ -140,4 +140,50 @@ Runtime notes with enough content to avoid incidental lint noise in the audit-lo
140
140
  assert.match(log, /rebuild-index \| wiki_rebuild_index: rebuilt \d+ entries from pages on disk \| tools-test-agent/);
141
141
  assert.match(log, /delete \| forget: deleted page pages\/shared\/chapterhouse\.md \| tools-test-agent/);
142
142
  });
143
+ test("wiki_fix defaults to dry-run previews and logs applied fixes in write mode", async () => {
144
+ const toolsModule = await loadToolsModule();
145
+ const tools = toolsModule.createTools({
146
+ client: { async listModels() { return []; } },
147
+ onAgentTaskComplete: () => { },
148
+ });
149
+ const wikiFix = tools.find((entry) => entry.name === "wiki_fix");
150
+ assert.ok(wikiFix);
151
+ const wikiFs = await readWikiArtifacts();
152
+ wikiFs.writePage("pages/_meta/taxonomy.md", `## Process
153
+ - runbook
154
+ `);
155
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", `---
156
+ title: Chapterhouse
157
+ summary: Runtime notes
158
+ updated: 2026-05-12
159
+ tags: [Run Book]
160
+ ---
161
+
162
+ # Chapterhouse
163
+
164
+ Runtime notes with enough content to avoid incidental stub handling.
165
+
166
+ ## Details
167
+
168
+ One
169
+ Two
170
+ Three
171
+ Four
172
+ Five
173
+ Six
174
+ Seven
175
+ Eight
176
+ `);
177
+ const before = wikiFs.readPage("pages/projects/chapterhouse/index.md");
178
+ const preview = await wikiFix.handler({});
179
+ assert.equal(typeof preview, "object");
180
+ assert.equal(preview.dryRun, true);
181
+ assert.equal(preview.changedFiles, 1);
182
+ assert.match(preview.diff, /--- a\/pages\/projects\/chapterhouse\/index\.md/);
183
+ assert.equal(wikiFs.readPage("pages/projects/chapterhouse/index.md"), before);
184
+ const applied = await wikiFix.handler({ dry_run: false });
185
+ assert.equal(applied.dryRun, false);
186
+ assert.match(wikiFs.readPage("pages/projects/chapterhouse/index.md") ?? "", /tags: \[runbook\]/);
187
+ assert.match(wikiFs.readLogFile(), /fix-tags \| pages\/projects\/chapterhouse\/index\.md \| tools-test-agent/);
188
+ });
143
189
  //# sourceMappingURL=tools.wiki.test.js.map
@@ -0,0 +1,335 @@
1
+ import { statSync } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+ import { resolveWikiRelativePath } from "../paths.js";
4
+ import { parseWikiFrontmatter } from "./frontmatter.js";
5
+ import { ensureWikiStructure, listPages, readPage, writePage } from "./fs.js";
6
+ import { loadTaxonomy } from "./taxonomy.js";
7
+ const DEFAULT_FIXES = [
8
+ "frontmatter-backfill",
9
+ "tag-normalize",
10
+ "autostub-mark",
11
+ ];
12
+ const KNOWN_FIELD_ORDER = [
13
+ "title",
14
+ "summary",
15
+ "updated",
16
+ "tags",
17
+ "autostub",
18
+ "confidence",
19
+ "contested",
20
+ "contradictions",
21
+ "related",
22
+ ];
23
+ export function fixWiki(options = {}) {
24
+ ensureWikiStructure();
25
+ const dryRun = options.dryRun ?? true;
26
+ const enabledFixes = new Set(options.fixes?.length ? options.fixes : DEFAULT_FIXES);
27
+ const pathFilter = options.pathGlob ? compileGlob(options.pathGlob) : undefined;
28
+ const allowedTags = enabledFixes.has("tag-normalize") ? loadTaxonomy() : [];
29
+ const canonicalTags = new Map(allowedTags.map((tag) => [normalizeTagKey(tag), tag]));
30
+ const reports = [];
31
+ const diffs = [];
32
+ let changedFiles = 0;
33
+ const pages = listPages()
34
+ .filter(isFixablePage)
35
+ .filter((path) => !pathFilter || pathFilter.test(path))
36
+ .sort();
37
+ for (const path of pages) {
38
+ const content = readPage(path);
39
+ if (!content)
40
+ continue;
41
+ const parsedResult = parseWikiFrontmatter(content);
42
+ if (parsedResult.parsed.metadata.autofix === false) {
43
+ continue;
44
+ }
45
+ const nextFrontmatter = cloneFrontmatter(parsedResult.parsed);
46
+ const body = parsedResult.body;
47
+ const changes = [];
48
+ let unknownTagsForFile = [];
49
+ if (enabledFixes.has("frontmatter-backfill")) {
50
+ let addedTitle = false;
51
+ let addedSummary = false;
52
+ let addedUpdated = false;
53
+ let addedAutostub = false;
54
+ if (!nextFrontmatter.title?.trim()) {
55
+ nextFrontmatter.title = inferTitle(path, body);
56
+ addedTitle = true;
57
+ }
58
+ if (!nextFrontmatter.summary?.trim()) {
59
+ const summary = inferSummary(body);
60
+ nextFrontmatter.summary = summary.text;
61
+ addedSummary = true;
62
+ if (summary.shouldMarkAutostub && nextFrontmatter.autostub !== true && nextFrontmatter.autostub === undefined) {
63
+ nextFrontmatter.autostub = true;
64
+ addedAutostub = true;
65
+ }
66
+ }
67
+ if (!nextFrontmatter.updated?.trim()) {
68
+ nextFrontmatter.updated = formatDate(statSync(resolveWikiRelativePath(path)).mtime);
69
+ addedUpdated = true;
70
+ }
71
+ const details = [
72
+ ...(addedTitle ? ["title"] : []),
73
+ ...(addedSummary ? ["summary"] : []),
74
+ ...(addedUpdated ? ["updated"] : []),
75
+ ...(addedAutostub ? ["autostub"] : []),
76
+ ];
77
+ if (details.length > 0) {
78
+ changes.push({ rule: "frontmatter-backfill", details });
79
+ }
80
+ }
81
+ if (enabledFixes.has("tag-normalize") && nextFrontmatter.tags?.length) {
82
+ const normalizedTags = [];
83
+ const details = [];
84
+ const unknownTags = [];
85
+ for (const tag of nextFrontmatter.tags) {
86
+ const normalized = canonicalTags.get(normalizeTagKey(tag));
87
+ if (normalized) {
88
+ normalizedTags.push(normalized);
89
+ if (normalized !== tag) {
90
+ details.push(`${tag} -> ${normalized}`);
91
+ }
92
+ }
93
+ else {
94
+ normalizedTags.push(tag);
95
+ unknownTags.push(tag);
96
+ }
97
+ }
98
+ if (details.length > 0) {
99
+ nextFrontmatter.tags = normalizedTags;
100
+ changes.push({ rule: "tag-normalize", details });
101
+ }
102
+ unknownTagsForFile = uniqueStrings(unknownTags);
103
+ }
104
+ if (enabledFixes.has("autostub-mark") && nextFrontmatter.autostub !== true && isStubBody(body)) {
105
+ nextFrontmatter.autostub = true;
106
+ changes.push({ rule: "autostub-mark", details: ["autostub"] });
107
+ }
108
+ if (changes.length === 0 && unknownTagsForFile.length === 0) {
109
+ continue;
110
+ }
111
+ const fileReport = {
112
+ path,
113
+ changes,
114
+ };
115
+ if (unknownTagsForFile.length > 0) {
116
+ fileReport.unknownTags = unknownTagsForFile;
117
+ }
118
+ reports.push(fileReport);
119
+ if (changes.length === 0) {
120
+ continue;
121
+ }
122
+ changedFiles += 1;
123
+ const nextContent = renderPage(nextFrontmatter, body);
124
+ if (dryRun) {
125
+ diffs.push(renderUnifiedDiff(path, content, nextContent));
126
+ continue;
127
+ }
128
+ writePage(path, nextContent);
129
+ for (const change of changes) {
130
+ const logType = logTypeForRule(change.rule);
131
+ if (logType) {
132
+ options.logAction?.(logType, path);
133
+ }
134
+ }
135
+ }
136
+ return {
137
+ dryRun,
138
+ scannedFiles: pages.length,
139
+ changedFiles,
140
+ files: reports,
141
+ diff: dryRun ? diffs.join("\n") : "",
142
+ };
143
+ }
144
+ function cloneFrontmatter(frontmatter) {
145
+ return {
146
+ ...frontmatter,
147
+ tags: frontmatter.tags ? [...frontmatter.tags] : undefined,
148
+ contradictions: frontmatter.contradictions ? [...frontmatter.contradictions] : undefined,
149
+ related: frontmatter.related ? [...frontmatter.related] : undefined,
150
+ metadata: { ...frontmatter.metadata },
151
+ };
152
+ }
153
+ function inferTitle(path, body) {
154
+ const heading = firstHeading(body);
155
+ if (heading) {
156
+ return stripMarkdown(heading);
157
+ }
158
+ return titleFromPath(path);
159
+ }
160
+ function inferSummary(body) {
161
+ const lines = body.split("\n");
162
+ const headingIndex = lines.findIndex((line) => /^#\s+/.test(line.trim()));
163
+ const startIndex = headingIndex >= 0 ? headingIndex + 1 : 0;
164
+ const paragraph = [];
165
+ for (let index = startIndex; index < lines.length; index += 1) {
166
+ const line = lines[index]?.trim() ?? "";
167
+ if (!line) {
168
+ if (paragraph.length > 0)
169
+ break;
170
+ continue;
171
+ }
172
+ if (line.startsWith("#")) {
173
+ if (paragraph.length > 0)
174
+ break;
175
+ continue;
176
+ }
177
+ paragraph.push(line);
178
+ }
179
+ const summary = stripMarkdown(paragraph.join(" ")).trim();
180
+ if (!summary) {
181
+ return {
182
+ text: "(no summary yet)",
183
+ shouldMarkAutostub: true,
184
+ };
185
+ }
186
+ return {
187
+ text: truncate(summary, 200),
188
+ shouldMarkAutostub: false,
189
+ };
190
+ }
191
+ function firstHeading(body) {
192
+ for (const rawLine of body.split("\n")) {
193
+ const match = rawLine.trim().match(/^#\s+(.+)$/);
194
+ if (match) {
195
+ return match[1].trim();
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+ function titleFromPath(path) {
201
+ const file = basename(path, ".md");
202
+ const stem = file === "index" ? basename(dirname(path)) : file;
203
+ return stem
204
+ .split(/[-_]+/)
205
+ .filter(Boolean)
206
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
207
+ .join(" ");
208
+ }
209
+ function stripMarkdown(value) {
210
+ return value
211
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
212
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
213
+ .replace(/[`*_~>#]/g, "")
214
+ .replace(/\s+/g, " ")
215
+ .trim();
216
+ }
217
+ function truncate(value, maxLength) {
218
+ if (value.length <= maxLength) {
219
+ return value;
220
+ }
221
+ return value.slice(0, maxLength).trimEnd();
222
+ }
223
+ function formatDate(date) {
224
+ return date.toISOString().slice(0, 10);
225
+ }
226
+ function normalizeTagKey(tag) {
227
+ return tag.toLowerCase().replace(/[^a-z0-9]+/g, "");
228
+ }
229
+ function isStubBody(body) {
230
+ const meaningfulLines = body
231
+ .split("\n")
232
+ .map((line) => line.trim())
233
+ .filter(Boolean);
234
+ return meaningfulLines.length < 10;
235
+ }
236
+ function renderPage(frontmatter, body) {
237
+ const lines = ["---"];
238
+ for (const field of KNOWN_FIELD_ORDER) {
239
+ const value = frontmatter[field];
240
+ if (value === undefined)
241
+ continue;
242
+ lines.push(formatFrontmatterLine(field, value));
243
+ }
244
+ for (const [key, value] of Object.entries(frontmatter.metadata)) {
245
+ lines.push(formatFrontmatterLine(key, value));
246
+ }
247
+ lines.push("---");
248
+ const normalizedBody = body.replace(/^\n+/, "");
249
+ if (!normalizedBody) {
250
+ return `${lines.join("\n")}\n`;
251
+ }
252
+ return `${lines.join("\n")}\n\n${normalizedBody.endsWith("\n") ? normalizedBody : `${normalizedBody}\n`}`;
253
+ }
254
+ function formatFrontmatterLine(key, value) {
255
+ if (Array.isArray(value)) {
256
+ return `${key}: [${value.map(formatInlineValue).join(", ")}]`;
257
+ }
258
+ if (typeof value === "boolean") {
259
+ return `${key}: ${value ? "true" : "false"}`;
260
+ }
261
+ return `${key}: ${formatScalar(value)}`;
262
+ }
263
+ function formatInlineValue(value) {
264
+ return /^[A-Za-z0-9_./()-]+$/.test(value) ? value : formatScalar(value);
265
+ }
266
+ function formatScalar(value) {
267
+ if (value === "" || /[:#[\]{}'",]|^\s|\s$/.test(value)) {
268
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
269
+ }
270
+ return value;
271
+ }
272
+ function uniqueStrings(values) {
273
+ return [...new Set(values)];
274
+ }
275
+ function renderUnifiedDiff(path, before, after) {
276
+ const beforeLines = splitLines(before);
277
+ const afterLines = splitLines(after);
278
+ const beforeCount = beforeLines.length;
279
+ const afterCount = afterLines.length;
280
+ const diffLines = [
281
+ `--- a/${path}`,
282
+ `+++ b/${path}`,
283
+ `@@ -1,${beforeCount} +1,${afterCount} @@`,
284
+ ...beforeLines.map((line) => `-${line}`),
285
+ ...afterLines.map((line) => `+${line}`),
286
+ ];
287
+ return diffLines.join("\n");
288
+ }
289
+ function splitLines(content) {
290
+ return content.endsWith("\n")
291
+ ? content.slice(0, -1).split("\n")
292
+ : content.split("\n");
293
+ }
294
+ function compileGlob(pattern) {
295
+ let regex = "^";
296
+ for (let index = 0; index < pattern.length; index += 1) {
297
+ const char = pattern[index];
298
+ const next = pattern[index + 1];
299
+ if (char === "*" && next === "*") {
300
+ regex += ".*";
301
+ index += 1;
302
+ continue;
303
+ }
304
+ if (char === "*") {
305
+ regex += "[^/]*";
306
+ continue;
307
+ }
308
+ if (char === "?") {
309
+ regex += "[^/]";
310
+ continue;
311
+ }
312
+ regex += escapeRegex(char);
313
+ }
314
+ regex += "$";
315
+ return new RegExp(regex);
316
+ }
317
+ function escapeRegex(value) {
318
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
319
+ }
320
+ function logTypeForRule(rule) {
321
+ switch (rule) {
322
+ case "frontmatter-backfill":
323
+ return "fix-frontmatter";
324
+ case "tag-normalize":
325
+ return "fix-tags";
326
+ case "autostub-mark":
327
+ return "fix-autostub";
328
+ default:
329
+ return undefined;
330
+ }
331
+ }
332
+ function isFixablePage(path) {
333
+ return !path.startsWith("pages/_meta/") && path !== "pages/index.md";
334
+ }
335
+ //# sourceMappingURL=fix.js.map