codealmanac 0.1.0

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.
@@ -0,0 +1,4036 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { basename as basename6 } from "path";
5
+ import { Command } from "commander";
6
+
7
+ // src/commands/bootstrap.ts
8
+ import { createWriteStream, existsSync as existsSync4 } from "fs";
9
+ import { readdir } from "fs/promises";
10
+ import { join as join4, relative } from "path";
11
+
12
+ // src/agent/auth.ts
13
+ import { spawn } from "child_process";
14
+ import { createRequire } from "module";
15
+ import { dirname, join } from "path";
16
+ var AUTH_TIMEOUT_MS = 1e4;
17
+ function resolveCliJsPath() {
18
+ const require2 = createRequire(import.meta.url);
19
+ const pkgJsonPath = require2.resolve(
20
+ "@anthropic-ai/claude-agent-sdk/package.json"
21
+ );
22
+ return join(dirname(pkgJsonPath), "cli.js");
23
+ }
24
+ var defaultSpawnCli = (args) => {
25
+ const cliPath = resolveCliJsPath();
26
+ const child = spawn(process.execPath, [cliPath, ...args], {
27
+ stdio: ["ignore", "pipe", "pipe"]
28
+ });
29
+ return child;
30
+ };
31
+ async function checkClaudeAuth(spawnCli = defaultSpawnCli) {
32
+ let child;
33
+ try {
34
+ child = spawnCli(["auth", "status", "--json"]);
35
+ } catch {
36
+ return { loggedIn: false };
37
+ }
38
+ return new Promise((resolve2) => {
39
+ let stdout = "";
40
+ let stderr = "";
41
+ let settled = false;
42
+ const settle = (value) => {
43
+ if (settled) return;
44
+ settled = true;
45
+ clearTimeout(timer);
46
+ resolve2(value);
47
+ };
48
+ const timer = setTimeout(() => {
49
+ try {
50
+ child.kill("SIGTERM");
51
+ } catch {
52
+ }
53
+ settle({ loggedIn: false });
54
+ }, AUTH_TIMEOUT_MS);
55
+ child.stdout.on("data", (data) => {
56
+ stdout += data.toString();
57
+ });
58
+ child.stderr.on("data", (data) => {
59
+ stderr += data.toString();
60
+ });
61
+ child.on("error", () => {
62
+ settle({ loggedIn: false });
63
+ });
64
+ child.on("close", (code) => {
65
+ if (code !== 0 && stdout.trim().length === 0) {
66
+ void stderr;
67
+ settle({ loggedIn: false });
68
+ return;
69
+ }
70
+ try {
71
+ const parsed = JSON.parse(stdout.trim());
72
+ const loggedIn = parsed.loggedIn === true;
73
+ const out = { loggedIn };
74
+ if (typeof parsed.email === "string") out.email = parsed.email;
75
+ if (typeof parsed.subscriptionType === "string") {
76
+ out.subscriptionType = parsed.subscriptionType;
77
+ }
78
+ if (typeof parsed.authMethod === "string") {
79
+ out.authMethod = parsed.authMethod;
80
+ }
81
+ settle(out);
82
+ } catch {
83
+ settle({ loggedIn: false });
84
+ }
85
+ });
86
+ });
87
+ }
88
+ var UNAUTHENTICATED_MESSAGE = "not authenticated to Claude.\n\nOption 1 \u2014 use your Claude subscription (Pro/Max):\n claude auth login --claudeai\n\nOption 2 \u2014 use a pay-per-token API key:\n Get one at https://console.anthropic.com\n export ANTHROPIC_API_KEY=sk-ant-...\n\nVerify with: claude auth status";
89
+ async function assertClaudeAuth(spawnCli = defaultSpawnCli) {
90
+ const status = await checkClaudeAuth(spawnCli);
91
+ if (status.loggedIn) {
92
+ return status;
93
+ }
94
+ const apiKey = process.env.ANTHROPIC_API_KEY;
95
+ if (apiKey !== void 0 && apiKey.length > 0) {
96
+ return { loggedIn: true, authMethod: "apiKey" };
97
+ }
98
+ const err = new Error(UNAUTHENTICATED_MESSAGE);
99
+ err.code = "CLAUDE_AUTH_MISSING";
100
+ throw err;
101
+ }
102
+
103
+ // src/agent/prompts.ts
104
+ import { existsSync } from "fs";
105
+ import { readFile } from "fs/promises";
106
+ import path from "path";
107
+ import { fileURLToPath } from "url";
108
+ var PROMPT_NAMES = [
109
+ "bootstrap",
110
+ "writer",
111
+ "reviewer"
112
+ ];
113
+ var overrideDir = null;
114
+ var resolvedDir = null;
115
+ function resolvePromptsDir() {
116
+ if (overrideDir !== null) return overrideDir;
117
+ if (resolvedDir !== null) return resolvedDir;
118
+ const here = path.dirname(fileURLToPath(import.meta.url));
119
+ const candidates = [
120
+ // Bundled dist layout: `.../<pkg>/dist/codealmanac.js` → `../prompts`
121
+ path.resolve(here, "..", "prompts"),
122
+ // Source layout: `.../<pkg>/src/agent/prompts.ts` → `../../prompts`
123
+ path.resolve(here, "..", "..", "prompts"),
124
+ // Defensive fallback: if tsup someday emits a nested `dist/src/agent`,
125
+ // walk up three levels.
126
+ path.resolve(here, "..", "..", "..", "prompts")
127
+ ];
128
+ for (const dir of candidates) {
129
+ if (isPromptsDir(dir)) {
130
+ resolvedDir = dir;
131
+ return dir;
132
+ }
133
+ }
134
+ throw new Error(
135
+ "could not locate bundled prompts/ directory. Tried:\n" + candidates.map((c) => ` - ${c}`).join("\n")
136
+ );
137
+ }
138
+ function isPromptsDir(dir) {
139
+ if (!existsSync(dir)) return false;
140
+ return PROMPT_NAMES.every(
141
+ (name) => existsSync(path.join(dir, `${name}.md`))
142
+ );
143
+ }
144
+ async function loadPrompt(name) {
145
+ const dir = resolvePromptsDir();
146
+ return readFile(path.join(dir, `${name}.md`), "utf8");
147
+ }
148
+
149
+ // src/agent/sdk.ts
150
+ import { query } from "@anthropic-ai/claude-agent-sdk";
151
+ async function runAgent(opts) {
152
+ const q = query({
153
+ prompt: opts.prompt,
154
+ options: {
155
+ systemPrompt: opts.systemPrompt,
156
+ allowedTools: opts.allowedTools,
157
+ agents: opts.agents ?? {},
158
+ cwd: opts.cwd,
159
+ model: opts.model ?? "claude-sonnet-4-6",
160
+ maxTurns: opts.maxTurns ?? 100,
161
+ // REQUIRED for streaming text deltas. Without it, `stream_event`
162
+ // messages never fire and the CLI has no progress visibility during
163
+ // long turns. See docs/research/agent-sdk.md §12 pitfall #1.
164
+ includePartialMessages: true
165
+ }
166
+ });
167
+ let cost = 0;
168
+ let turns = 0;
169
+ let result = "";
170
+ let sessionId;
171
+ let success = false;
172
+ let errorMsg;
173
+ try {
174
+ for await (const msg of q) {
175
+ opts.onMessage?.(msg);
176
+ if (sessionId === void 0 && typeof msg.session_id === "string") {
177
+ sessionId = msg.session_id;
178
+ }
179
+ if (msg.type === "result") {
180
+ cost = msg.total_cost_usd;
181
+ turns = msg.num_turns;
182
+ if (msg.subtype === "success") {
183
+ success = true;
184
+ result = msg.result;
185
+ } else {
186
+ success = false;
187
+ errorMsg = // `SDKResultError` variants don't carry a `result` string; the
188
+ // useful detail lives in `errors` (array of strings) or the
189
+ // subtype itself (e.g. "error_max_turns").
190
+ (msg.errors?.join("; ") ?? "") || `agent error: ${msg.subtype}`;
191
+ }
192
+ }
193
+ }
194
+ } catch (err) {
195
+ errorMsg = err instanceof Error ? err.message : String(err);
196
+ success = false;
197
+ }
198
+ return { success, cost, turns, result, sessionId, error: errorMsg };
199
+ }
200
+
201
+ // src/paths.ts
202
+ import { existsSync as existsSync2 } from "fs";
203
+ import { homedir } from "os";
204
+ import { dirname as dirname2, isAbsolute, join as join2, resolve } from "path";
205
+ function getGlobalAlmanacDir() {
206
+ return join2(homedir(), ".almanac");
207
+ }
208
+ function getRegistryPath() {
209
+ return join2(getGlobalAlmanacDir(), "registry.json");
210
+ }
211
+ function getRepoAlmanacDir(cwd) {
212
+ return join2(cwd, ".almanac");
213
+ }
214
+ function findNearestAlmanacDir(startDir) {
215
+ const globalDir = getGlobalAlmanacDir();
216
+ let current = isAbsolute(startDir) ? startDir : resolve(startDir);
217
+ while (true) {
218
+ const candidate = join2(current, ".almanac");
219
+ if (candidate !== globalDir && existsSync2(candidate)) {
220
+ return current;
221
+ }
222
+ const parent = dirname2(current);
223
+ if (parent === current) {
224
+ return null;
225
+ }
226
+ current = parent;
227
+ }
228
+ }
229
+
230
+ // src/commands/init.ts
231
+ import { existsSync as existsSync3 } from "fs";
232
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
233
+ import { basename, join as join3 } from "path";
234
+
235
+ // src/slug.ts
236
+ function toKebabCase(input) {
237
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
238
+ }
239
+
240
+ // src/registry/index.ts
241
+ import { mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
242
+ import { dirname as dirname3 } from "path";
243
+ async function readRegistry() {
244
+ const path3 = getRegistryPath();
245
+ let raw;
246
+ try {
247
+ raw = await readFile2(path3, "utf8");
248
+ } catch (err) {
249
+ if (isNodeError(err) && err.code === "ENOENT") {
250
+ return [];
251
+ }
252
+ throw err;
253
+ }
254
+ const trimmed = raw.trim();
255
+ if (trimmed.length === 0) {
256
+ return [];
257
+ }
258
+ let parsed;
259
+ try {
260
+ parsed = JSON.parse(trimmed);
261
+ } catch (err) {
262
+ const message = err instanceof Error ? err.message : String(err);
263
+ throw new Error(`registry at ${path3} is not valid JSON: ${message}`);
264
+ }
265
+ if (!Array.isArray(parsed)) {
266
+ throw new Error(`registry at ${path3} must be a JSON array`);
267
+ }
268
+ return parsed.map((item, idx) => {
269
+ if (typeof item !== "object" || item === null) {
270
+ throw new Error(`registry entry ${idx} is not an object`);
271
+ }
272
+ const e = item;
273
+ const name = typeof e.name === "string" ? e.name : "";
274
+ const path4 = typeof e.path === "string" ? e.path : "";
275
+ if (name.length === 0) {
276
+ throw new Error(`registry entry ${idx} is missing a non-empty "name"`);
277
+ }
278
+ if (path4.length === 0) {
279
+ throw new Error(`registry entry ${idx} is missing a non-empty "path"`);
280
+ }
281
+ return {
282
+ name,
283
+ description: typeof e.description === "string" ? e.description : "",
284
+ path: path4,
285
+ registered_at: typeof e.registered_at === "string" ? e.registered_at : ""
286
+ };
287
+ });
288
+ }
289
+ async function writeRegistry(entries) {
290
+ const path3 = getRegistryPath();
291
+ await mkdir(dirname3(path3), { recursive: true });
292
+ const body = `${JSON.stringify(entries, null, 2)}
293
+ `;
294
+ const tmpPath = `${path3}.tmp`;
295
+ await writeFile(tmpPath, body, "utf8");
296
+ await rename(tmpPath, path3);
297
+ }
298
+ function pathsEqual(a, b) {
299
+ if (process.platform === "darwin" || process.platform === "win32") {
300
+ return a.toLowerCase() === b.toLowerCase();
301
+ }
302
+ return a === b;
303
+ }
304
+ async function addEntry(entry) {
305
+ const existing = await readRegistry();
306
+ const filtered = existing.filter(
307
+ (e) => e.name !== entry.name && !pathsEqual(e.path, entry.path)
308
+ );
309
+ filtered.push(entry);
310
+ await writeRegistry(filtered);
311
+ return filtered;
312
+ }
313
+ async function dropEntry(name) {
314
+ const existing = await readRegistry();
315
+ const idx = existing.findIndex((e) => e.name === name);
316
+ if (idx === -1) {
317
+ return null;
318
+ }
319
+ const [removed] = existing.splice(idx, 1);
320
+ await writeRegistry(existing);
321
+ return removed ?? null;
322
+ }
323
+ async function findEntry(params) {
324
+ const entries = await readRegistry();
325
+ for (const entry of entries) {
326
+ if (params.name !== void 0 && entry.name === params.name) return entry;
327
+ if (params.path !== void 0 && pathsEqual(entry.path, params.path)) {
328
+ return entry;
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+ async function ensureGlobalDir() {
334
+ await mkdir(getGlobalAlmanacDir(), { recursive: true });
335
+ }
336
+ function isNodeError(err) {
337
+ return err instanceof Error && "code" in err;
338
+ }
339
+
340
+ // src/commands/init.ts
341
+ async function initWiki(options) {
342
+ const repoRoot = findNearestAlmanacDir(options.cwd) ?? options.cwd;
343
+ const almanacDir = getRepoAlmanacDir(repoRoot);
344
+ const pagesDir = join3(almanacDir, "pages");
345
+ const readmePath = join3(almanacDir, "README.md");
346
+ const alreadyExisted = existsSync3(almanacDir);
347
+ await mkdir2(pagesDir, { recursive: true });
348
+ if (!existsSync3(readmePath)) {
349
+ await writeFile2(readmePath, starterReadme(), "utf8");
350
+ }
351
+ await ensureGitignoreHasIndexDb(repoRoot);
352
+ const name = toKebabCase(options.name ?? basename(repoRoot));
353
+ if (name.length === 0) {
354
+ throw new Error(
355
+ "could not derive a wiki name from the current directory; pass --name"
356
+ );
357
+ }
358
+ const description = (options.description ?? "").trim();
359
+ await ensureGlobalDir();
360
+ const entry = {
361
+ name,
362
+ description,
363
+ path: repoRoot,
364
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
365
+ };
366
+ await addEntry(entry);
367
+ return { entry, almanacDir, created: !alreadyExisted };
368
+ }
369
+ async function ensureGitignoreHasIndexDb(cwd) {
370
+ const path3 = join3(cwd, ".gitignore");
371
+ const targets = [
372
+ ".almanac/index.db",
373
+ ".almanac/index.db-wal",
374
+ ".almanac/index.db-shm"
375
+ ];
376
+ let existing = "";
377
+ if (existsSync3(path3)) {
378
+ existing = await readFile3(path3, "utf8");
379
+ }
380
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
381
+ const missing = targets.filter((t) => !lines.includes(t));
382
+ if (missing.length === 0) return;
383
+ const hasHeader = lines.includes("# codealmanac");
384
+ const block = hasHeader ? missing.join("\n") + "\n" : `# codealmanac
385
+ ${missing.join("\n")}
386
+ `;
387
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
388
+ await writeFile2(path3, `${existing}${sep}${block}`, "utf8");
389
+ }
390
+ function starterReadme() {
391
+ return `# Wiki
392
+
393
+ This is the codealmanac wiki for this repository. It captures the knowledge
394
+ the code itself can't say \u2014 decisions, flows, invariants, gotchas, incidents.
395
+
396
+ The primary reader is an AI coding agent. The secondary reader is a human
397
+ skimming to understand the shape of the codebase. Write accordingly: dense,
398
+ factual, linked.
399
+
400
+ ## Notability bar
401
+
402
+ Write a page when there is **non-obvious knowledge that will help a future
403
+ agent**. Specifically:
404
+
405
+ - A decision that took discussion, research, or trial-and-error
406
+ - A gotcha discovered through failure
407
+ - A cross-cutting flow that spans multiple files and isn't obvious from any
408
+ one of them
409
+ - A constraint or invariant not visible from the code
410
+ - An entity (technology, service, system) referenced by multiple pages
411
+
412
+ Do not write pages that restate what the code does. Do not write pages of
413
+ inference \u2014 only of observation. Silence is an acceptable outcome.
414
+
415
+ ## Topic taxonomy
416
+
417
+ Topics form a DAG; pages can belong to multiple topics. Start with these and
418
+ grow as the wiki does:
419
+
420
+ - \`stack\` \u2014 technologies and services we use (frameworks, databases, APIs)
421
+ - \`systems\` \u2014 custom systems we built (auth, billing, search)
422
+ - \`flows\` \u2014 multi-file processes end-to-end (checkout-flow, publish-flow)
423
+ - \`decisions\` \u2014 "why X over Y"
424
+ - \`incidents\` \u2014 recorded failures and their fixes
425
+ - \`concepts\` \u2014 shared vocabulary specific to this codebase
426
+
427
+ Domain topics (\`auth\`, \`payments\`, \`frontend\`, \`backend\`) live alongside
428
+ these. A page about JWT rotation belongs to both \`auth\` and \`decisions\`.
429
+
430
+ ## Page shapes
431
+
432
+ Four shapes cover most of what gets written. They are suggestions, not a
433
+ schema \u2014 a page that fits none of them is fine.
434
+
435
+ - **Entity** \u2014 a stable named thing (Supabase, Stripe, the search service)
436
+ - **Decision** \u2014 why we chose X over Y
437
+ - **Flow** \u2014 how a multi-file process works end-to-end
438
+ - **Gotcha** \u2014 a specific surprise, failure, or constraint
439
+
440
+ ## Writing conventions
441
+
442
+ - Every sentence contains a specific fact. If it doesn't, cut it.
443
+ - Neutral tone. "is", not "serves as". No "plays a pivotal role", no
444
+ interpretive "-ing" clauses, no vague attribution ("experts argue").
445
+ - No hedging or knowledge-gap disclaimers. If you don't know, don't write
446
+ the sentence.
447
+ - Prose first. Bullets for genuine lists. Tables only for structured
448
+ comparison.
449
+ - No formulaic conclusions. End with the last substantive fact.
450
+
451
+ ## Linking
452
+
453
+ One \`[[...]]\` syntax for everything, disambiguated by content:
454
+
455
+ - \`[[checkout-flow]]\` \u2014 page slug
456
+ - \`[[src/checkout/handler.ts]]\` \u2014 file reference
457
+ - \`[[src/checkout/]]\` \u2014 folder reference (trailing slash)
458
+ - \`[[other-wiki:slug]]\` \u2014 cross-wiki reference
459
+
460
+ Every page should link to at least one entity when possible. A page with no
461
+ entity link is suspect.
462
+
463
+ ## Pages live in \`.almanac/pages/\`
464
+
465
+ One markdown file per page, kebab-case slug. Frontmatter carries \`topics:\`
466
+ and optional \`files:\`. The rest is prose.
467
+ `;
468
+ }
469
+
470
+ // src/commands/bootstrap.ts
471
+ var BOOTSTRAP_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash"];
472
+ async function runBootstrap(options) {
473
+ try {
474
+ await assertClaudeAuth(options.spawnCli);
475
+ } catch (err) {
476
+ const msg = err instanceof Error ? err.message : String(err);
477
+ return {
478
+ stdout: "",
479
+ stderr: `almanac: ${msg}
480
+ `,
481
+ exitCode: 1
482
+ };
483
+ }
484
+ const repoRoot = findNearestAlmanacDir(options.cwd) ?? options.cwd;
485
+ const almanacDir = getRepoAlmanacDir(repoRoot);
486
+ const pagesDir = join4(almanacDir, "pages");
487
+ if (options.force !== true && existsSync4(pagesDir)) {
488
+ const existing = await countMarkdownPages(pagesDir);
489
+ if (existing > 0) {
490
+ return {
491
+ stdout: "",
492
+ stderr: `almanac: .almanac/ already initialized with ${existing} page${existing === 1 ? "" : "s"}. Use 'almanac capture' instead, or --force to overwrite.
493
+ `,
494
+ exitCode: 1
495
+ };
496
+ }
497
+ }
498
+ if (!existsSync4(almanacDir)) {
499
+ try {
500
+ await initWiki({ cwd: repoRoot });
501
+ } catch (err) {
502
+ const msg = err instanceof Error ? err.message : String(err);
503
+ return {
504
+ stdout: "",
505
+ stderr: `almanac: init failed during bootstrap: ${msg}
506
+ `,
507
+ exitCode: 1
508
+ };
509
+ }
510
+ }
511
+ const systemPrompt = await loadPrompt("bootstrap");
512
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
513
+ const logName = `.bootstrap-${formatTimestamp(now)}.log`;
514
+ const logPath = join4(almanacDir, logName);
515
+ const logStream = createWriteStream(logPath, { flags: "w" });
516
+ const out = process.stdout;
517
+ const formatter = new StreamingFormatter({
518
+ write: (line) => {
519
+ if (options.quiet !== true) out.write(line);
520
+ }
521
+ });
522
+ const onMessage = (msg) => {
523
+ try {
524
+ logStream.write(`${JSON.stringify(msg)}
525
+ `);
526
+ } catch {
527
+ }
528
+ formatter.handle(msg);
529
+ };
530
+ const runner = options.runAgent ?? runAgent;
531
+ const userPrompt = `Begin the bootstrap now. Working directory: ${repoRoot}.`;
532
+ let result;
533
+ try {
534
+ result = await runner({
535
+ systemPrompt,
536
+ prompt: userPrompt,
537
+ allowedTools: BOOTSTRAP_TOOLS,
538
+ cwd: repoRoot,
539
+ model: options.model,
540
+ onMessage
541
+ });
542
+ } finally {
543
+ await closeStream(logStream);
544
+ }
545
+ const finalLine = formatFinalLine(result, logPath, repoRoot);
546
+ if (result.success) {
547
+ return {
548
+ stdout: `${finalLine}
549
+ `,
550
+ stderr: "",
551
+ exitCode: 0
552
+ };
553
+ }
554
+ return {
555
+ stdout: options.quiet === true ? "" : `${finalLine}
556
+ `,
557
+ stderr: `almanac: bootstrap failed: ${result.error ?? "unknown error"}
558
+ `,
559
+ exitCode: 1
560
+ };
561
+ }
562
+ function formatFinalLine(result, logPath, repoRoot) {
563
+ const status = result.success ? "done" : "failed";
564
+ const rel = relative(repoRoot, logPath);
565
+ const cost = `$${result.cost.toFixed(3)}`;
566
+ return `[${status}] cost: ${cost}, turns: ${result.turns} (transcript: ${rel})`;
567
+ }
568
+ async function countMarkdownPages(pagesDir) {
569
+ try {
570
+ const entries = await readdir(pagesDir, { withFileTypes: true });
571
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).length;
572
+ } catch {
573
+ return 0;
574
+ }
575
+ }
576
+ function closeStream(stream) {
577
+ return new Promise((resolve2) => {
578
+ stream.end(() => resolve2());
579
+ });
580
+ }
581
+ function formatTimestamp(d) {
582
+ const pad = (n) => n.toString().padStart(2, "0");
583
+ const y = d.getFullYear();
584
+ const mo = pad(d.getMonth() + 1);
585
+ const da = pad(d.getDate());
586
+ const h = pad(d.getHours());
587
+ const mi = pad(d.getMinutes());
588
+ const s = pad(d.getSeconds());
589
+ return `${y}${mo}${da}-${h}${mi}${s}`;
590
+ }
591
+ var StreamingFormatter = class {
592
+ sink;
593
+ /**
594
+ * Current agent label. Starts as "bootstrap"; switches when we see an
595
+ * `Agent` tool-use (slice 5 will exercise this). We still track it here
596
+ * so the formatter can stay shared between bootstrap and capture.
597
+ */
598
+ currentAgent = "bootstrap";
599
+ constructor(sink) {
600
+ this.sink = sink;
601
+ }
602
+ /**
603
+ * Swap the top-level agent label. `capture` uses this to relabel from
604
+ * the default "bootstrap" to "writer" — otherwise the writer's tool-use
605
+ * output would render as `[bootstrap] …`, which is confusing when you're
606
+ * reading capture logs.
607
+ */
608
+ setAgent(name) {
609
+ this.currentAgent = name;
610
+ }
611
+ handle(msg) {
612
+ if (msg.type === "assistant") {
613
+ for (const block of msg.message.content) {
614
+ if (block.type !== "tool_use") continue;
615
+ this.handleToolUse(block.name, block.input);
616
+ }
617
+ return;
618
+ }
619
+ if (msg.type === "result") {
620
+ const status = msg.subtype === "success" ? "done" : `failed (${msg.subtype})`;
621
+ this.sink.write(
622
+ `[${status}] cost: $${msg.total_cost_usd.toFixed(3)}, turns: ${msg.num_turns}
623
+ `
624
+ );
625
+ return;
626
+ }
627
+ }
628
+ handleToolUse(name, rawInput) {
629
+ const input = normalizeToolInput(rawInput);
630
+ if (name === "Agent") {
631
+ const sub = typeof input.subagent_type === "string" ? input.subagent_type : "subagent";
632
+ this.currentAgent = sub;
633
+ this.sink.write(`[${sub}] starting
634
+ `);
635
+ return;
636
+ }
637
+ const summary = formatToolSummary(name, input);
638
+ this.sink.write(`[${this.currentAgent}] ${summary}
639
+ `);
640
+ }
641
+ };
642
+ function normalizeToolInput(raw) {
643
+ if (typeof raw === "string") {
644
+ try {
645
+ const parsed = JSON.parse(raw);
646
+ if (parsed !== null && typeof parsed === "object") {
647
+ return parsed;
648
+ }
649
+ } catch {
650
+ }
651
+ return {};
652
+ }
653
+ if (raw !== null && typeof raw === "object") {
654
+ return raw;
655
+ }
656
+ return {};
657
+ }
658
+ function formatToolSummary(name, input) {
659
+ switch (name) {
660
+ case "Read": {
661
+ const target = stringField(input, "file_path") ?? "?";
662
+ return `reading ${target}`;
663
+ }
664
+ case "Write": {
665
+ const target = stringField(input, "file_path") ?? "?";
666
+ return `writing ${target}`;
667
+ }
668
+ case "Edit": {
669
+ const target = stringField(input, "file_path") ?? "?";
670
+ return `editing ${target}`;
671
+ }
672
+ case "Glob": {
673
+ const pattern = stringField(input, "pattern") ?? "?";
674
+ return `glob ${pattern}`;
675
+ }
676
+ case "Grep": {
677
+ const pattern = stringField(input, "pattern") ?? "?";
678
+ return `grep ${pattern}`;
679
+ }
680
+ case "Bash": {
681
+ const command = stringField(input, "command") ?? "?";
682
+ const trimmed = command.length > 80 ? `${command.slice(0, 77)}...` : command;
683
+ return `bash ${trimmed}`;
684
+ }
685
+ default: {
686
+ return name;
687
+ }
688
+ }
689
+ }
690
+ function stringField(input, key) {
691
+ const value = input[key];
692
+ return typeof value === "string" ? value : void 0;
693
+ }
694
+
695
+ // src/commands/capture.ts
696
+ import { createHash } from "crypto";
697
+ import {
698
+ createWriteStream as createWriteStream2,
699
+ existsSync as existsSync5,
700
+ statSync
701
+ } from "fs";
702
+ import { readFile as readFile4, readdir as readdir2, stat } from "fs/promises";
703
+ import { homedir as homedir2 } from "os";
704
+ import { basename as basename2, join as join5, relative as relative2 } from "path";
705
+
706
+ // src/indexer/frontmatter.ts
707
+ import yaml from "js-yaml";
708
+ function parseFrontmatter(raw) {
709
+ const empty = {
710
+ topics: [],
711
+ files: [],
712
+ archived_at: null,
713
+ superseded_by: null,
714
+ supersedes: null,
715
+ body: raw
716
+ };
717
+ if (!raw.startsWith("---")) {
718
+ return empty;
719
+ }
720
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
721
+ if (match === null) {
722
+ return empty;
723
+ }
724
+ const yamlBody = match[1] ?? "";
725
+ const body = match[2] ?? "";
726
+ let parsed;
727
+ try {
728
+ parsed = yaml.load(yamlBody);
729
+ } catch (err) {
730
+ const message = err instanceof Error ? err.message : String(err);
731
+ process.stderr.write(`almanac: malformed frontmatter (${message})
732
+ `);
733
+ return empty;
734
+ }
735
+ if (parsed === null || parsed === void 0) {
736
+ return { ...empty, body };
737
+ }
738
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
739
+ return { ...empty, body };
740
+ }
741
+ const obj = parsed;
742
+ return {
743
+ title: coerceString(obj.title),
744
+ topics: coerceStringArray(obj.topics),
745
+ files: coerceStringArray(obj.files),
746
+ archived_at: coerceEpochSeconds(obj.archived_at),
747
+ superseded_by: coerceString(obj.superseded_by) ?? null,
748
+ supersedes: coerceString(obj.supersedes) ?? null,
749
+ body
750
+ };
751
+ }
752
+ function firstH1(body) {
753
+ const lines = body.split(/\r?\n/, 40);
754
+ for (const line of lines) {
755
+ const m = line.match(/^#\s+(.+?)\s*#*\s*$/);
756
+ if (m !== null) {
757
+ return m[1];
758
+ }
759
+ }
760
+ return void 0;
761
+ }
762
+ function coerceString(v) {
763
+ if (typeof v === "string" && v.trim().length > 0) return v.trim();
764
+ return void 0;
765
+ }
766
+ function coerceStringArray(v) {
767
+ if (!Array.isArray(v)) return [];
768
+ const out = [];
769
+ for (const item of v) {
770
+ if (typeof item === "string" && item.trim().length > 0) {
771
+ out.push(item.trim());
772
+ }
773
+ }
774
+ return out;
775
+ }
776
+ function coerceEpochSeconds(v) {
777
+ if (v instanceof Date) {
778
+ return Math.floor(v.getTime() / 1e3);
779
+ }
780
+ if (typeof v === "number" && Number.isFinite(v)) {
781
+ return Math.floor(v);
782
+ }
783
+ if (typeof v === "string" && v.trim().length > 0) {
784
+ const t = Date.parse(v.trim());
785
+ if (!Number.isNaN(t)) {
786
+ return Math.floor(t / 1e3);
787
+ }
788
+ }
789
+ return null;
790
+ }
791
+
792
+ // src/commands/capture.ts
793
+ var WRITER_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent"];
794
+ var REVIEWER_TOOLS = ["Read", "Grep", "Glob", "Bash"];
795
+ var REVIEWER_DESCRIPTION = "Reviews proposed wiki changes against the full knowledge base for cohesion, duplication, missing links, notability, and writing conventions.";
796
+ async function runCapture(options) {
797
+ try {
798
+ await assertClaudeAuth(options.spawnCli);
799
+ } catch (err) {
800
+ const msg = err instanceof Error ? err.message : String(err);
801
+ return {
802
+ stdout: "",
803
+ stderr: `almanac: ${msg}
804
+ `,
805
+ exitCode: 1
806
+ };
807
+ }
808
+ const repoRoot = findNearestAlmanacDir(options.cwd);
809
+ if (repoRoot === null) {
810
+ return {
811
+ stdout: "",
812
+ stderr: "almanac: no .almanac/ found in this directory or any parent. Run 'almanac init' or 'almanac bootstrap' first.\n",
813
+ exitCode: 1
814
+ };
815
+ }
816
+ const almanacDir = getRepoAlmanacDir(repoRoot);
817
+ const pagesDir = join5(almanacDir, "pages");
818
+ const transcriptResolution = await resolveTranscript({
819
+ repoRoot,
820
+ explicit: options.transcriptPath,
821
+ sessionId: options.sessionId,
822
+ claudeProjectsDir: options.claudeProjectsDir
823
+ });
824
+ if (!transcriptResolution.ok) {
825
+ return {
826
+ stdout: "",
827
+ stderr: `almanac: ${transcriptResolution.error}
828
+ `,
829
+ exitCode: 1
830
+ };
831
+ }
832
+ const transcriptPath = transcriptResolution.path;
833
+ const snapshotBefore = await snapshotPages(pagesDir);
834
+ const systemPrompt = await loadPrompt("writer");
835
+ const reviewerPrompt = await loadPrompt("reviewer");
836
+ const agents = {
837
+ reviewer: {
838
+ description: REVIEWER_DESCRIPTION,
839
+ prompt: reviewerPrompt,
840
+ tools: REVIEWER_TOOLS
841
+ }
842
+ };
843
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
844
+ const logName = `.capture-${formatTimestamp2(now)}.log`;
845
+ const logPath = join5(almanacDir, logName);
846
+ const logStream = createWriteStream2(logPath, { flags: "w" });
847
+ const out = process.stdout;
848
+ const formatter = new StreamingFormatter({
849
+ write: (line) => {
850
+ if (options.quiet !== true) out.write(line);
851
+ }
852
+ });
853
+ formatter.setAgent("writer");
854
+ const onMessage = (msg) => {
855
+ try {
856
+ logStream.write(`${JSON.stringify(msg)}
857
+ `);
858
+ } catch {
859
+ }
860
+ formatter.handle(msg);
861
+ };
862
+ const userPrompt = `Capture this coding session.
863
+ Transcript: ${transcriptPath}.
864
+ Working directory: ${repoRoot}.`;
865
+ const runner = options.runAgent ?? runAgent;
866
+ let result;
867
+ try {
868
+ result = await runner({
869
+ systemPrompt,
870
+ prompt: userPrompt,
871
+ allowedTools: WRITER_TOOLS,
872
+ agents,
873
+ cwd: repoRoot,
874
+ model: options.model,
875
+ // Capture sessions can touch many pages; give it more headroom than
876
+ // bootstrap. The SDK treats `maxTurns` as a hard stop — better to
877
+ // overshoot than to cut off mid-review.
878
+ maxTurns: 150,
879
+ onMessage
880
+ });
881
+ } finally {
882
+ await closeStream2(logStream);
883
+ }
884
+ const snapshotAfter = await snapshotPages(pagesDir);
885
+ const delta = diffSnapshots(snapshotBefore, snapshotAfter);
886
+ if (!result.success) {
887
+ return {
888
+ stdout: "",
889
+ stderr: `almanac: capture failed: ${result.error ?? "unknown error"}
890
+ (transcript: ${relative2(repoRoot, logPath)})
891
+ `,
892
+ exitCode: 1
893
+ };
894
+ }
895
+ const summary = formatSummary(result, delta, logPath, repoRoot);
896
+ return {
897
+ stdout: `${summary}
898
+ `,
899
+ stderr: "",
900
+ exitCode: 0
901
+ };
902
+ }
903
+ async function resolveTranscript(args) {
904
+ if (args.explicit !== void 0 && args.explicit.length > 0) {
905
+ if (!existsSync5(args.explicit)) {
906
+ return {
907
+ ok: false,
908
+ error: `transcript not found: ${args.explicit}`
909
+ };
910
+ }
911
+ return { ok: true, path: args.explicit };
912
+ }
913
+ const projectsDir = args.claudeProjectsDir ?? join5(homedir2(), ".claude", "projects");
914
+ if (!existsSync5(projectsDir)) {
915
+ return {
916
+ ok: false,
917
+ error: `could not auto-resolve transcript; ${projectsDir} does not exist. Pass --session <id> or <transcript-path>.`
918
+ };
919
+ }
920
+ const allTranscripts = await collectTranscripts(projectsDir);
921
+ if (args.sessionId !== void 0 && args.sessionId.length > 0) {
922
+ const expected = `${args.sessionId}.jsonl`;
923
+ const match = allTranscripts.find((t) => basename2(t.path) === expected);
924
+ if (match === void 0) {
925
+ return {
926
+ ok: false,
927
+ error: `no transcript found for session ${args.sessionId} under ${projectsDir}`
928
+ };
929
+ }
930
+ return { ok: true, path: match.path };
931
+ }
932
+ const matches = await filterTranscriptsByCwd(allTranscripts, args.repoRoot);
933
+ if (matches.length === 0) {
934
+ return {
935
+ ok: false,
936
+ error: `could not auto-resolve transcript under ${projectsDir}; no session matches cwd ${args.repoRoot}. Pass --session <id> or <transcript-path>.`
937
+ };
938
+ }
939
+ matches.sort((a, b) => b.mtime - a.mtime);
940
+ return { ok: true, path: matches[0].path };
941
+ }
942
+ async function collectTranscripts(projectsDir) {
943
+ const out = [];
944
+ let topLevel;
945
+ try {
946
+ topLevel = await readdir2(projectsDir);
947
+ } catch {
948
+ return out;
949
+ }
950
+ for (const name of topLevel) {
951
+ const projectDir = join5(projectsDir, name);
952
+ let entries;
953
+ try {
954
+ entries = await readdir2(projectDir);
955
+ } catch {
956
+ continue;
957
+ }
958
+ for (const entry of entries) {
959
+ if (!entry.endsWith(".jsonl")) continue;
960
+ const full = join5(projectDir, entry);
961
+ try {
962
+ const st = await stat(full);
963
+ if (st.isFile()) {
964
+ out.push({ path: full, mtime: st.mtimeMs });
965
+ }
966
+ } catch {
967
+ }
968
+ }
969
+ }
970
+ return out;
971
+ }
972
+ async function filterTranscriptsByCwd(transcripts, repoRoot) {
973
+ const dirHash = `-${repoRoot.replace(/^\/+/, "").replace(/\//g, "-")}`;
974
+ const byDirName = transcripts.filter((t) => {
975
+ const parent = basename2(join5(t.path, ".."));
976
+ return parent === dirHash || parent.endsWith(dirHash);
977
+ });
978
+ if (byDirName.length > 0) return byDirName;
979
+ const needle = `"cwd":"${repoRoot}"`;
980
+ const hits = [];
981
+ for (const t of transcripts) {
982
+ try {
983
+ const head = await readHead(t.path, 4096);
984
+ if (head.includes(needle)) hits.push(t);
985
+ } catch {
986
+ continue;
987
+ }
988
+ }
989
+ return hits;
990
+ }
991
+ async function readHead(path3, bytes) {
992
+ const content = await readFile4(path3, "utf8");
993
+ return content.length > bytes ? content.slice(0, bytes) : content;
994
+ }
995
+ async function snapshotPages(pagesDir) {
996
+ const out = /* @__PURE__ */ new Map();
997
+ if (!existsSync5(pagesDir)) return out;
998
+ let entries;
999
+ try {
1000
+ entries = await readdir2(pagesDir);
1001
+ } catch {
1002
+ return out;
1003
+ }
1004
+ for (const entry of entries) {
1005
+ if (!entry.endsWith(".md")) continue;
1006
+ const slug = entry.slice(0, -3);
1007
+ const full = join5(pagesDir, entry);
1008
+ try {
1009
+ const st = statSync(full);
1010
+ if (!st.isFile()) continue;
1011
+ const content = await readFile4(full, "utf8");
1012
+ const hash = createHash("sha256").update(content).digest("hex");
1013
+ const fm = parseFrontmatter(content);
1014
+ out.set(slug, {
1015
+ slug,
1016
+ hash,
1017
+ archived: fm.archived_at !== null
1018
+ });
1019
+ } catch {
1020
+ continue;
1021
+ }
1022
+ }
1023
+ return out;
1024
+ }
1025
+ function diffSnapshots(before, after) {
1026
+ let created = 0;
1027
+ let updated = 0;
1028
+ let archived = 0;
1029
+ for (const [slug, entry] of after) {
1030
+ const prev = before.get(slug);
1031
+ if (prev === void 0) {
1032
+ created += 1;
1033
+ continue;
1034
+ }
1035
+ if (prev.hash !== entry.hash) {
1036
+ if (!prev.archived && entry.archived) {
1037
+ archived += 1;
1038
+ } else {
1039
+ updated += 1;
1040
+ }
1041
+ }
1042
+ }
1043
+ return { created, updated, archived };
1044
+ }
1045
+ function formatSummary(result, delta, logPath, repoRoot) {
1046
+ const rel = relative2(repoRoot, logPath);
1047
+ const cost = `$${result.cost.toFixed(3)}`;
1048
+ const { created, updated, archived } = delta;
1049
+ if (created === 0 && updated === 0 && archived === 0) {
1050
+ return `[capture] no new knowledge met the notability bar (0 pages written), cost: ${cost}, turns: ${result.turns} (transcript: ${rel})`;
1051
+ }
1052
+ return `[done] ${updated} page${updated === 1 ? "" : "s"} updated, ${created} created, ${archived} archived, cost: ${cost}, turns: ${result.turns} (transcript: ${rel})`;
1053
+ }
1054
+ function formatTimestamp2(d) {
1055
+ const pad = (n) => n.toString().padStart(2, "0");
1056
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
1057
+ }
1058
+ function closeStream2(stream) {
1059
+ return new Promise((resolve2) => {
1060
+ stream.end(() => resolve2());
1061
+ });
1062
+ }
1063
+
1064
+ // src/commands/hook.ts
1065
+ import { existsSync as existsSync6 } from "fs";
1066
+ import { mkdir as mkdir3, readFile as readFile5, rename as rename2, writeFile as writeFile3 } from "fs/promises";
1067
+ import { homedir as homedir3 } from "os";
1068
+ import path2 from "path";
1069
+ import { fileURLToPath as fileURLToPath2 } from "url";
1070
+ var HOOK_TIMEOUT_SECONDS = 10;
1071
+ async function runHookInstall(options = {}) {
1072
+ const script = resolveHookScriptPath(options);
1073
+ if (!script.ok) {
1074
+ return { stdout: "", stderr: `almanac: ${script.error}
1075
+ `, exitCode: 1 };
1076
+ }
1077
+ const settingsPath = resolveSettingsPath(options);
1078
+ const settings = await readSettings(settingsPath);
1079
+ const existing = (settings.hooks?.SessionEnd ?? []).slice();
1080
+ const ourEntries = existing.filter((e) => e.command === script.path);
1081
+ const foreignEntries = existing.filter((e) => e.command !== script.path);
1082
+ const stale = foreignEntries.filter(
1083
+ (e) => e.command.endsWith("almanac-capture.sh")
1084
+ );
1085
+ const unrelated = foreignEntries.filter(
1086
+ (e) => !e.command.endsWith("almanac-capture.sh")
1087
+ );
1088
+ if (unrelated.length > 0) {
1089
+ const existingStr = unrelated.map((e) => ` - ${e.command}`).join("\n");
1090
+ return {
1091
+ stdout: "",
1092
+ stderr: `almanac: SessionEnd hook already has a foreign entry:
1093
+ ${existingStr}
1094
+ Remove it manually from ${settingsPath} if you want almanac to manage the hook.
1095
+ `,
1096
+ exitCode: 1
1097
+ };
1098
+ }
1099
+ if (ourEntries.length > 0 && stale.length === 0) {
1100
+ return {
1101
+ stdout: `almanac: SessionEnd hook already installed at ${script.path}
1102
+ `,
1103
+ stderr: "",
1104
+ exitCode: 0
1105
+ };
1106
+ }
1107
+ const newEntries = [
1108
+ {
1109
+ type: "command",
1110
+ command: script.path,
1111
+ timeout: HOOK_TIMEOUT_SECONDS
1112
+ }
1113
+ ];
1114
+ settings.hooks = { ...settings.hooks ?? {}, SessionEnd: newEntries };
1115
+ await writeSettings(settingsPath, settings);
1116
+ return {
1117
+ stdout: `almanac: SessionEnd hook installed
1118
+ script: ${script.path}
1119
+ settings: ${settingsPath}
1120
+ `,
1121
+ stderr: "",
1122
+ exitCode: 0
1123
+ };
1124
+ }
1125
+ async function runHookUninstall(options = {}) {
1126
+ const settingsPath = resolveSettingsPath(options);
1127
+ if (!existsSync6(settingsPath)) {
1128
+ return {
1129
+ stdout: `almanac: SessionEnd hook not installed (no settings file)
1130
+ `,
1131
+ stderr: "",
1132
+ exitCode: 0
1133
+ };
1134
+ }
1135
+ const settings = await readSettings(settingsPath);
1136
+ const existing = (settings.hooks?.SessionEnd ?? []).slice();
1137
+ const kept = existing.filter((e) => !e.command.endsWith("almanac-capture.sh"));
1138
+ const removed = existing.length - kept.length;
1139
+ if (removed === 0) {
1140
+ return {
1141
+ stdout: `almanac: SessionEnd hook not installed
1142
+ `,
1143
+ stderr: "",
1144
+ exitCode: 0
1145
+ };
1146
+ }
1147
+ if (settings.hooks !== void 0) {
1148
+ if (kept.length === 0) {
1149
+ const { SessionEnd: _dropped, ...rest } = settings.hooks;
1150
+ void _dropped;
1151
+ settings.hooks = rest;
1152
+ } else {
1153
+ settings.hooks = { ...settings.hooks, SessionEnd: kept };
1154
+ }
1155
+ if (Object.keys(settings.hooks).length === 0) {
1156
+ delete settings.hooks;
1157
+ }
1158
+ }
1159
+ await writeSettings(settingsPath, settings);
1160
+ return {
1161
+ stdout: `almanac: SessionEnd hook removed
1162
+ `,
1163
+ stderr: "",
1164
+ exitCode: 0
1165
+ };
1166
+ }
1167
+ async function runHookStatus(options = {}) {
1168
+ const script = resolveHookScriptPath(options);
1169
+ const settingsPath = resolveSettingsPath(options);
1170
+ if (!existsSync6(settingsPath)) {
1171
+ return {
1172
+ stdout: `SessionEnd hook: not installed
1173
+ settings: ${settingsPath} (does not exist)
1174
+ ` + (script.ok ? `script would be: ${script.path}
1175
+ ` : ""),
1176
+ stderr: "",
1177
+ exitCode: 0
1178
+ };
1179
+ }
1180
+ const settings = await readSettings(settingsPath);
1181
+ const existing = settings.hooks?.SessionEnd ?? [];
1182
+ const ours = existing.find((e) => e.command.endsWith("almanac-capture.sh"));
1183
+ if (ours === void 0) {
1184
+ const foreign = existing.map((e) => ` - ${e.command}`).join("\n");
1185
+ return {
1186
+ stdout: `SessionEnd hook: not installed
1187
+ settings: ${settingsPath}
1188
+ ` + (existing.length > 0 ? `(${existing.length} foreign entr${existing.length === 1 ? "y" : "ies"} present:
1189
+ ${foreign})
1190
+ ` : "") + (script.ok ? `script would be: ${script.path}
1191
+ ` : ""),
1192
+ stderr: "",
1193
+ exitCode: 0
1194
+ };
1195
+ }
1196
+ return {
1197
+ stdout: `SessionEnd hook: installed
1198
+ script: ${ours.command}
1199
+ settings: ${settingsPath}
1200
+ `,
1201
+ stderr: "",
1202
+ exitCode: 0
1203
+ };
1204
+ }
1205
+ function resolveSettingsPath(options) {
1206
+ if (options.settingsPath !== void 0) return options.settingsPath;
1207
+ return path2.join(homedir3(), ".claude", "settings.json");
1208
+ }
1209
+ function resolveHookScriptPath(options) {
1210
+ if (options.hookScriptPath !== void 0) {
1211
+ return { ok: true, path: options.hookScriptPath };
1212
+ }
1213
+ const here = path2.dirname(fileURLToPath2(import.meta.url));
1214
+ const candidates = [
1215
+ // Bundled: `.../codealmanac/dist/codealmanac.js` → `../hooks/…`
1216
+ path2.resolve(here, "..", "hooks", "almanac-capture.sh"),
1217
+ // Source: `.../codealmanac/src/commands/hook.ts` → `../../hooks/…`
1218
+ path2.resolve(here, "..", "..", "hooks", "almanac-capture.sh"),
1219
+ // Defensive nested fallback.
1220
+ path2.resolve(here, "..", "..", "..", "hooks", "almanac-capture.sh")
1221
+ ];
1222
+ for (const candidate of candidates) {
1223
+ if (existsSync6(candidate)) {
1224
+ return { ok: true, path: candidate };
1225
+ }
1226
+ }
1227
+ return {
1228
+ ok: false,
1229
+ error: `could not locate hooks/almanac-capture.sh. Tried:
1230
+ ` + candidates.map((c) => ` - ${c}`).join("\n")
1231
+ };
1232
+ }
1233
+ async function readSettings(settingsPath) {
1234
+ if (!existsSync6(settingsPath)) return {};
1235
+ try {
1236
+ const raw = await readFile5(settingsPath, "utf8");
1237
+ if (raw.trim().length === 0) return {};
1238
+ const parsed = JSON.parse(raw);
1239
+ if (parsed === null || typeof parsed !== "object") return {};
1240
+ return parsed;
1241
+ } catch (err) {
1242
+ const msg = err instanceof Error ? err.message : String(err);
1243
+ throw new Error(`failed to read ${settingsPath}: ${msg}`);
1244
+ }
1245
+ }
1246
+ async function writeSettings(settingsPath, settings) {
1247
+ const dir = path2.dirname(settingsPath);
1248
+ await mkdir3(dir, { recursive: true });
1249
+ const tmp = `${settingsPath}.almanac-tmp-${process.pid}`;
1250
+ const body = `${JSON.stringify(settings, null, 2)}
1251
+ `;
1252
+ await writeFile3(tmp, body, "utf8");
1253
+ await rename2(tmp, settingsPath);
1254
+ }
1255
+
1256
+ // src/commands/health.ts
1257
+ import { existsSync as existsSync10 } from "fs";
1258
+ import { readFile as readFile8 } from "fs/promises";
1259
+ import { basename as basename4, join as join8 } from "path";
1260
+ import fg2 from "fast-glob";
1261
+
1262
+ // src/indexer/duration.ts
1263
+ function parseDuration(input) {
1264
+ const trimmed = input.trim();
1265
+ const m = trimmed.match(/^(\d+)([mhdw])$/);
1266
+ if (m === null) {
1267
+ throw new Error(
1268
+ `invalid duration "${input}" (expected Nw, Nd, Nh, or Nm \u2014 e.g. 2w, 30d)`
1269
+ );
1270
+ }
1271
+ const n = Number.parseInt(m[1] ?? "0", 10);
1272
+ const unit = m[2];
1273
+ switch (unit) {
1274
+ case "m":
1275
+ return n * 60;
1276
+ case "h":
1277
+ return n * 60 * 60;
1278
+ case "d":
1279
+ return n * 60 * 60 * 24;
1280
+ case "w":
1281
+ return n * 60 * 60 * 24 * 7;
1282
+ default:
1283
+ throw new Error(`invalid duration unit "${unit ?? ""}"`);
1284
+ }
1285
+ }
1286
+
1287
+ // src/indexer/index.ts
1288
+ import { createHash as createHash2 } from "crypto";
1289
+ import { existsSync as existsSync8, statSync as statSync2 } from "fs";
1290
+ import { readFile as readFile7, utimes } from "fs/promises";
1291
+ import { basename as basename3, join as join6, relative as relative3 } from "path";
1292
+ import fg from "fast-glob";
1293
+
1294
+ // src/topics/yaml.ts
1295
+ import { existsSync as existsSync7 } from "fs";
1296
+ import { mkdir as mkdir4, readFile as readFile6, rename as rename3, writeFile as writeFile4 } from "fs/promises";
1297
+ import { dirname as dirname4 } from "path";
1298
+ import yaml2 from "js-yaml";
1299
+ async function loadTopicsFile(path3) {
1300
+ if (!existsSync7(path3)) {
1301
+ return { topics: [] };
1302
+ }
1303
+ let raw;
1304
+ try {
1305
+ raw = await readFile6(path3, "utf8");
1306
+ } catch (err) {
1307
+ if (isNodeError2(err) && err.code === "ENOENT") {
1308
+ return { topics: [] };
1309
+ }
1310
+ throw err;
1311
+ }
1312
+ const trimmed = raw.trim();
1313
+ if (trimmed.length === 0) {
1314
+ return { topics: [] };
1315
+ }
1316
+ let parsed;
1317
+ try {
1318
+ parsed = yaml2.load(raw);
1319
+ } catch (err) {
1320
+ const message = err instanceof Error ? err.message : String(err);
1321
+ throw new Error(`topics.yaml at ${path3} is not valid YAML: ${message}`);
1322
+ }
1323
+ if (parsed === null || parsed === void 0) {
1324
+ return { topics: [] };
1325
+ }
1326
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
1327
+ throw new Error(`topics.yaml at ${path3} must be a mapping`);
1328
+ }
1329
+ const obj = parsed;
1330
+ const rawTopics = obj.topics;
1331
+ if (rawTopics === void 0 || rawTopics === null) {
1332
+ return { topics: [] };
1333
+ }
1334
+ if (!Array.isArray(rawTopics)) {
1335
+ throw new Error(`topics.yaml at ${path3} \u2014 "topics" must be a list`);
1336
+ }
1337
+ const topics = [];
1338
+ for (const item of rawTopics) {
1339
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
1340
+ continue;
1341
+ }
1342
+ const entry = item;
1343
+ const slugRaw = entry.slug;
1344
+ if (typeof slugRaw !== "string" || slugRaw.trim().length === 0) continue;
1345
+ const slug = toKebabCase(slugRaw);
1346
+ if (slug.length === 0) continue;
1347
+ const title = typeof entry.title === "string" && entry.title.trim().length > 0 ? entry.title.trim() : titleCase(slug);
1348
+ const description = typeof entry.description === "string" && entry.description.trim().length > 0 ? entry.description.trim() : null;
1349
+ const parents = [];
1350
+ if (Array.isArray(entry.parents)) {
1351
+ for (const p of entry.parents) {
1352
+ if (typeof p === "string" && p.trim().length > 0) {
1353
+ const ps = toKebabCase(p);
1354
+ if (ps.length > 0 && ps !== slug && !parents.includes(ps)) {
1355
+ parents.push(ps);
1356
+ }
1357
+ }
1358
+ }
1359
+ }
1360
+ topics.push({ slug, title, description, parents });
1361
+ }
1362
+ return { topics };
1363
+ }
1364
+ async function writeTopicsFile(path3, file) {
1365
+ const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));
1366
+ const doc = {
1367
+ topics: sorted.map((t) => {
1368
+ return {
1369
+ slug: t.slug,
1370
+ title: t.title,
1371
+ description: t.description,
1372
+ parents: t.parents
1373
+ };
1374
+ })
1375
+ };
1376
+ const header = `# .almanac/topics.yaml \u2014 source of truth for topic metadata.
1377
+ # Managed by \`almanac topics\` commands. User-added comments
1378
+ # between entries will be stripped on the next write (js-yaml
1379
+ # doesn't round-trip comments). Edit at your own risk \u2014 or use the
1380
+ # CLI (\`almanac topics create|link|describe|rename|delete\`)
1381
+ # which preserves the structure correctly.
1382
+ `;
1383
+ const body = yaml2.dump(doc, {
1384
+ lineWidth: 100,
1385
+ noRefs: true,
1386
+ sortKeys: false
1387
+ });
1388
+ const content = `${header}${body}`;
1389
+ const tmpPath = `${path3}.tmp`;
1390
+ const parent = dirname4(path3);
1391
+ if (!existsSync7(parent)) {
1392
+ await mkdir4(parent, { recursive: true });
1393
+ }
1394
+ await writeFile4(tmpPath, content, "utf8");
1395
+ await rename3(tmpPath, path3);
1396
+ }
1397
+ function findTopic(file, slug) {
1398
+ for (const t of file.topics) {
1399
+ if (t.slug === slug) return t;
1400
+ }
1401
+ return null;
1402
+ }
1403
+ function ensureTopic(file, slug) {
1404
+ const existing = findTopic(file, slug);
1405
+ if (existing !== null) return existing;
1406
+ const entry = {
1407
+ slug,
1408
+ title: titleCase(slug),
1409
+ description: null,
1410
+ parents: []
1411
+ };
1412
+ file.topics.push(entry);
1413
+ return entry;
1414
+ }
1415
+ function titleCase(slug) {
1416
+ if (slug.length === 0) return slug;
1417
+ return slug.split("-").filter((s) => s.length > 0).map((s) => `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`).join(" ");
1418
+ }
1419
+ function isNodeError2(err) {
1420
+ return err instanceof Error && "code" in err;
1421
+ }
1422
+
1423
+ // src/indexer/paths.ts
1424
+ function normalizePath(raw, isDir) {
1425
+ const normalized = normalizeShape(raw, isDir);
1426
+ return normalized.toLowerCase();
1427
+ }
1428
+ function normalizePathPreservingCase(raw, isDir) {
1429
+ return normalizeShape(raw, isDir);
1430
+ }
1431
+ function normalizeShape(raw, isDir) {
1432
+ let s = raw.trim();
1433
+ s = s.replace(/\\+/g, "/");
1434
+ while (s.startsWith("./")) s = s.slice(2);
1435
+ s = s.replace(/\/+/g, "/");
1436
+ s = s.replace(/\/+$/, "");
1437
+ if (isDir) {
1438
+ return `${s}/`;
1439
+ }
1440
+ return s;
1441
+ }
1442
+ function looksLikeDir(raw) {
1443
+ const s = raw.trim().replace(/\\+/g, "/");
1444
+ return s.endsWith("/");
1445
+ }
1446
+
1447
+ // src/indexer/schema.ts
1448
+ import Database from "better-sqlite3";
1449
+ var SCHEMA_DDL = `
1450
+ CREATE TABLE IF NOT EXISTS pages (
1451
+ slug TEXT PRIMARY KEY,
1452
+ title TEXT,
1453
+ file_path TEXT NOT NULL,
1454
+ content_hash TEXT NOT NULL,
1455
+ updated_at INTEGER NOT NULL,
1456
+ archived_at INTEGER,
1457
+ superseded_by TEXT
1458
+ );
1459
+
1460
+ CREATE TABLE IF NOT EXISTS topics (
1461
+ slug TEXT PRIMARY KEY,
1462
+ title TEXT,
1463
+ description TEXT
1464
+ );
1465
+
1466
+ CREATE TABLE IF NOT EXISTS page_topics (
1467
+ page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1468
+ topic_slug TEXT NOT NULL,
1469
+ PRIMARY KEY (page_slug, topic_slug)
1470
+ );
1471
+
1472
+ CREATE TABLE IF NOT EXISTS topic_parents (
1473
+ child_slug TEXT NOT NULL,
1474
+ parent_slug TEXT NOT NULL,
1475
+ PRIMARY KEY (child_slug, parent_slug),
1476
+ CHECK (child_slug != parent_slug)
1477
+ );
1478
+
1479
+ CREATE TABLE IF NOT EXISTS file_refs (
1480
+ page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1481
+ path TEXT NOT NULL,
1482
+ original_path TEXT NOT NULL,
1483
+ is_dir INTEGER NOT NULL,
1484
+ PRIMARY KEY (page_slug, path)
1485
+ );
1486
+ CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
1487
+
1488
+ CREATE TABLE IF NOT EXISTS wikilinks (
1489
+ source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1490
+ target_slug TEXT NOT NULL,
1491
+ PRIMARY KEY (source_slug, target_slug)
1492
+ );
1493
+
1494
+ CREATE TABLE IF NOT EXISTS cross_wiki_links (
1495
+ source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1496
+ target_wiki TEXT NOT NULL,
1497
+ target_slug TEXT NOT NULL,
1498
+ PRIMARY KEY (source_slug, target_wiki, target_slug)
1499
+ );
1500
+
1501
+ -- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
1502
+ -- The indexer must explicitly DELETE FROM fts_pages whenever it removes
1503
+ -- or replaces a page row, or we leak orphaned FTS rows.
1504
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
1505
+ `;
1506
+ var SCHEMA_VERSION = 2;
1507
+ function openIndex(dbPath) {
1508
+ const db = new Database(dbPath);
1509
+ const mode = db.pragma("journal_mode", { simple: true });
1510
+ if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
1511
+ db.pragma("journal_mode = WAL");
1512
+ }
1513
+ db.pragma("foreign_keys = ON");
1514
+ const rawVersion = db.pragma("user_version", { simple: true });
1515
+ const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
1516
+ if (currentVersion < SCHEMA_VERSION) {
1517
+ db.exec("DROP TABLE IF EXISTS file_refs");
1518
+ try {
1519
+ db.exec("UPDATE pages SET content_hash = ''");
1520
+ } catch {
1521
+ }
1522
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
1523
+ }
1524
+ db.exec(SCHEMA_DDL);
1525
+ return db;
1526
+ }
1527
+
1528
+ // src/indexer/wikilinks.ts
1529
+ function classifyWikilink(raw) {
1530
+ const pipe = raw.indexOf("|");
1531
+ let body = pipe === -1 ? raw : raw.slice(0, pipe);
1532
+ body = body.trim();
1533
+ if (body.length === 0) return null;
1534
+ const firstColon = body.indexOf(":");
1535
+ const firstSlash = body.indexOf("/");
1536
+ if (firstColon !== -1 && (firstSlash === -1 || firstColon < firstSlash)) {
1537
+ const wiki = body.slice(0, firstColon).trim();
1538
+ const target2 = body.slice(firstColon + 1).trim();
1539
+ if (wiki.length === 0 || target2.length === 0) return null;
1540
+ return { kind: "xwiki", wiki, target: target2 };
1541
+ }
1542
+ if (firstSlash !== -1) {
1543
+ const isDir = looksLikeDir(body);
1544
+ const path3 = normalizePath(body, isDir);
1545
+ const originalPath = normalizePathPreservingCase(body, isDir);
1546
+ if (path3.length === 0) return null;
1547
+ return isDir ? { kind: "folder", path: path3, originalPath } : { kind: "file", path: path3, originalPath };
1548
+ }
1549
+ const target = toKebabCase(body);
1550
+ if (target.length === 0) return null;
1551
+ return { kind: "page", target };
1552
+ }
1553
+ function extractWikilinks(body) {
1554
+ const out = [];
1555
+ const re = /\[\[([^\]\n]+)\]\]/g;
1556
+ let m;
1557
+ while ((m = re.exec(body)) !== null) {
1558
+ const ref = classifyWikilink(m[1] ?? "");
1559
+ if (ref !== null) out.push(ref);
1560
+ }
1561
+ return out;
1562
+ }
1563
+
1564
+ // src/indexer/index.ts
1565
+ var TOPICS_YAML_FILENAME = "topics.yaml";
1566
+ var PAGES_GLOB = "**/*.md";
1567
+ async function ensureFreshIndex(ctx) {
1568
+ const almanacDir = join6(ctx.repoRoot, ".almanac");
1569
+ const dbPath = join6(almanacDir, "index.db");
1570
+ const pagesDir = join6(almanacDir, "pages");
1571
+ if (!existsSync8(pagesDir)) {
1572
+ const db = openIndex(dbPath);
1573
+ db.close();
1574
+ return emptyResult();
1575
+ }
1576
+ if (!existsSync8(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
1577
+ return runIndexer(ctx);
1578
+ }
1579
+ return emptyResult();
1580
+ }
1581
+ function emptyResult() {
1582
+ return {
1583
+ changed: 0,
1584
+ removed: 0,
1585
+ total: 0,
1586
+ pagesIndexed: 0,
1587
+ filesSeen: 0,
1588
+ filesSkipped: 0
1589
+ };
1590
+ }
1591
+ async function runIndexer(ctx) {
1592
+ const almanacDir = join6(ctx.repoRoot, ".almanac");
1593
+ const dbPath = join6(almanacDir, "index.db");
1594
+ const pagesDir = join6(almanacDir, "pages");
1595
+ const db = openIndex(dbPath);
1596
+ let result;
1597
+ try {
1598
+ result = await indexPagesInto(db, pagesDir);
1599
+ await applyTopicsYaml(db, join6(almanacDir, TOPICS_YAML_FILENAME));
1600
+ } finally {
1601
+ db.close();
1602
+ }
1603
+ try {
1604
+ const now = /* @__PURE__ */ new Date();
1605
+ await utimes(dbPath, now, now);
1606
+ } catch {
1607
+ }
1608
+ return result;
1609
+ }
1610
+ async function indexPagesInto(db, pagesDir) {
1611
+ const files = await fg(PAGES_GLOB, {
1612
+ cwd: pagesDir,
1613
+ absolute: false,
1614
+ onlyFiles: true,
1615
+ caseSensitiveMatch: true
1616
+ });
1617
+ const existingRows = db.prepare("SELECT slug, content_hash, file_path FROM pages").all();
1618
+ const existingBySlug = /* @__PURE__ */ new Map();
1619
+ for (const row of existingRows) existingBySlug.set(row.slug, row);
1620
+ const planned = [];
1621
+ const seenSlugs = /* @__PURE__ */ new Set();
1622
+ let filesSkipped = 0;
1623
+ for (const rel of files) {
1624
+ const fullPath = join6(pagesDir, rel);
1625
+ const base = basename3(rel, ".md");
1626
+ const slug = toKebabCase(base);
1627
+ if (slug.length === 0) {
1628
+ process.stderr.write(
1629
+ `almanac: skipping "${rel}" \u2014 filename has no slug-able characters
1630
+ `
1631
+ );
1632
+ filesSkipped++;
1633
+ continue;
1634
+ }
1635
+ if (slug !== base) {
1636
+ process.stderr.write(
1637
+ `almanac: warning \u2014 "${rel}" is not canonical; indexed as slug "${slug}"
1638
+ `
1639
+ );
1640
+ }
1641
+ if (seenSlugs.has(slug)) {
1642
+ process.stderr.write(
1643
+ `almanac: warning \u2014 slug "${slug}" collides with an earlier file; skipping "${rel}"
1644
+ `
1645
+ );
1646
+ filesSkipped++;
1647
+ continue;
1648
+ }
1649
+ let st;
1650
+ let raw;
1651
+ try {
1652
+ st = statSync2(fullPath);
1653
+ raw = await readFile7(fullPath, "utf8");
1654
+ } catch (err) {
1655
+ if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES")) {
1656
+ process.stderr.write(
1657
+ `almanac: skipping "${rel}" \u2014 ${err.message}
1658
+ `
1659
+ );
1660
+ filesSkipped++;
1661
+ continue;
1662
+ }
1663
+ throw err;
1664
+ }
1665
+ seenSlugs.add(slug);
1666
+ const updatedAt = Math.floor(st.mtimeMs / 1e3);
1667
+ const contentHash = hashContent(raw);
1668
+ const existing = existingBySlug.get(slug);
1669
+ if (existing !== void 0 && existing.content_hash === contentHash && existing.file_path === fullPath) {
1670
+ continue;
1671
+ }
1672
+ const fm = parseFrontmatter(raw);
1673
+ const title = fm.title ?? firstH1(fm.body) ?? base;
1674
+ const links = extractWikilinks(fm.body);
1675
+ planned.push({
1676
+ slug,
1677
+ title,
1678
+ filePath: rel,
1679
+ fullPath,
1680
+ contentHash,
1681
+ updatedAt,
1682
+ archivedAt: fm.archived_at,
1683
+ supersededBy: fm.superseded_by,
1684
+ topics: fm.topics,
1685
+ frontmatterFiles: fm.files,
1686
+ wikilinks: links,
1687
+ content: fm.body
1688
+ });
1689
+ }
1690
+ const toDelete = [];
1691
+ for (const slug of existingBySlug.keys()) {
1692
+ if (!seenSlugs.has(slug)) toDelete.push(slug);
1693
+ }
1694
+ const deleteByPage = db.prepare("DELETE FROM pages WHERE slug = ?");
1695
+ const deleteFtsByPage = db.prepare(
1696
+ "DELETE FROM fts_pages WHERE slug = ?"
1697
+ );
1698
+ const replacePage = db.prepare(
1699
+ `INSERT INTO pages (slug, title, file_path, content_hash, updated_at, archived_at, superseded_by)
1700
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1701
+ ON CONFLICT(slug) DO UPDATE SET
1702
+ title = excluded.title,
1703
+ file_path = excluded.file_path,
1704
+ content_hash = excluded.content_hash,
1705
+ updated_at = excluded.updated_at,
1706
+ archived_at = excluded.archived_at,
1707
+ superseded_by = excluded.superseded_by`
1708
+ );
1709
+ const deletePageTopics = db.prepare(
1710
+ "DELETE FROM page_topics WHERE page_slug = ?"
1711
+ );
1712
+ const insertPageTopic = db.prepare(
1713
+ "INSERT OR IGNORE INTO page_topics (page_slug, topic_slug) VALUES (?, ?)"
1714
+ );
1715
+ const insertTopic = db.prepare(
1716
+ "INSERT OR IGNORE INTO topics (slug, title) VALUES (?, ?)"
1717
+ );
1718
+ const deleteFileRefs = db.prepare(
1719
+ "DELETE FROM file_refs WHERE page_slug = ?"
1720
+ );
1721
+ const insertFileRef = db.prepare(
1722
+ "INSERT OR IGNORE INTO file_refs (page_slug, path, original_path, is_dir) VALUES (?, ?, ?, ?)"
1723
+ );
1724
+ const deleteWikilinks = db.prepare(
1725
+ "DELETE FROM wikilinks WHERE source_slug = ?"
1726
+ );
1727
+ const insertWikilink = db.prepare(
1728
+ "INSERT OR IGNORE INTO wikilinks (source_slug, target_slug) VALUES (?, ?)"
1729
+ );
1730
+ const deleteXwiki = db.prepare(
1731
+ "DELETE FROM cross_wiki_links WHERE source_slug = ?"
1732
+ );
1733
+ const insertXwiki = db.prepare(
1734
+ "INSERT OR IGNORE INTO cross_wiki_links (source_slug, target_wiki, target_slug) VALUES (?, ?, ?)"
1735
+ );
1736
+ const insertFts = db.prepare(
1737
+ "INSERT INTO fts_pages (slug, title, content) VALUES (?, ?, ?)"
1738
+ );
1739
+ const apply = db.transaction(() => {
1740
+ for (const slug of toDelete) {
1741
+ deleteFtsByPage.run(slug);
1742
+ deleteByPage.run(slug);
1743
+ }
1744
+ for (const p of planned) {
1745
+ deletePageTopics.run(p.slug);
1746
+ deleteFileRefs.run(p.slug);
1747
+ deleteWikilinks.run(p.slug);
1748
+ deleteXwiki.run(p.slug);
1749
+ deleteFtsByPage.run(p.slug);
1750
+ replacePage.run(
1751
+ p.slug,
1752
+ p.title,
1753
+ p.fullPath,
1754
+ p.contentHash,
1755
+ p.updatedAt,
1756
+ p.archivedAt,
1757
+ p.supersededBy
1758
+ );
1759
+ for (const topic of p.topics) {
1760
+ const topicSlug = toKebabCase(topic);
1761
+ if (topicSlug.length === 0) continue;
1762
+ insertTopic.run(topicSlug, titleCase(topicSlug));
1763
+ insertPageTopic.run(p.slug, topicSlug);
1764
+ }
1765
+ for (const raw of p.frontmatterFiles) {
1766
+ const isDir = looksLikeDir(raw);
1767
+ const path3 = normalizePath(raw, isDir);
1768
+ const originalPath = normalizePathPreservingCase(raw, isDir);
1769
+ if (path3.length === 0) continue;
1770
+ insertFileRef.run(p.slug, path3, originalPath, isDir ? 1 : 0);
1771
+ }
1772
+ for (const ref of p.wikilinks) {
1773
+ switch (ref.kind) {
1774
+ case "page":
1775
+ insertWikilink.run(p.slug, ref.target);
1776
+ break;
1777
+ case "file":
1778
+ insertFileRef.run(p.slug, ref.path, ref.originalPath, 0);
1779
+ break;
1780
+ case "folder":
1781
+ insertFileRef.run(p.slug, ref.path, ref.originalPath, 1);
1782
+ break;
1783
+ case "xwiki":
1784
+ insertXwiki.run(p.slug, ref.wiki, ref.target);
1785
+ break;
1786
+ }
1787
+ }
1788
+ insertFts.run(p.slug, p.title, p.content);
1789
+ }
1790
+ });
1791
+ apply();
1792
+ void relative3;
1793
+ const pagesIndexed = seenSlugs.size;
1794
+ return {
1795
+ changed: planned.length,
1796
+ removed: toDelete.length,
1797
+ total: pagesIndexed,
1798
+ pagesIndexed,
1799
+ filesSeen: files.length,
1800
+ filesSkipped
1801
+ };
1802
+ }
1803
+ function pagesNewerThan(pagesDir, dbPath) {
1804
+ let dbMtime;
1805
+ try {
1806
+ dbMtime = statSync2(dbPath).mtimeMs;
1807
+ } catch {
1808
+ return true;
1809
+ }
1810
+ const entries = fg.sync(PAGES_GLOB, {
1811
+ cwd: pagesDir,
1812
+ absolute: true,
1813
+ onlyFiles: true,
1814
+ stats: true
1815
+ });
1816
+ for (const entry of entries) {
1817
+ const mtime = entry.stats?.mtimeMs;
1818
+ if (mtime !== void 0 && mtime > dbMtime) return true;
1819
+ }
1820
+ return false;
1821
+ }
1822
+ function hashContent(raw) {
1823
+ return createHash2("sha256").update(raw).digest("hex");
1824
+ }
1825
+ function topicsYamlNewerThan(almanacDir, dbPath) {
1826
+ const path3 = join6(almanacDir, "topics.yaml");
1827
+ if (!existsSync8(path3)) return false;
1828
+ let dbMtime;
1829
+ try {
1830
+ dbMtime = statSync2(dbPath).mtimeMs;
1831
+ } catch {
1832
+ return true;
1833
+ }
1834
+ try {
1835
+ const st = statSync2(path3);
1836
+ return st.mtimeMs > dbMtime;
1837
+ } catch {
1838
+ return false;
1839
+ }
1840
+ }
1841
+ async function applyTopicsYaml(db, topicsYamlPath2) {
1842
+ if (!existsSync8(topicsYamlPath2)) return;
1843
+ let file;
1844
+ try {
1845
+ file = await loadTopicsFile(topicsYamlPath2);
1846
+ } catch (err) {
1847
+ const message = err instanceof Error ? err.message : String(err);
1848
+ process.stderr.write(`almanac: ${message}
1849
+ `);
1850
+ return;
1851
+ }
1852
+ const upsertTopic = db.prepare(
1853
+ `INSERT INTO topics (slug, title, description) VALUES (?, ?, ?)
1854
+ ON CONFLICT(slug) DO UPDATE SET
1855
+ title = excluded.title,
1856
+ description = excluded.description`
1857
+ );
1858
+ const clearParents = db.prepare(
1859
+ "DELETE FROM topic_parents WHERE child_slug = ?"
1860
+ );
1861
+ const insertParent = db.prepare(
1862
+ "INSERT OR IGNORE INTO topic_parents (child_slug, parent_slug) VALUES (?, ?)"
1863
+ );
1864
+ const declared = /* @__PURE__ */ new Set();
1865
+ for (const t of file.topics) declared.add(t.slug);
1866
+ const adHoc = db.prepare(
1867
+ "SELECT DISTINCT topic_slug FROM page_topics"
1868
+ ).all();
1869
+ for (const r of adHoc) declared.add(r.topic_slug);
1870
+ const apply = db.transaction(() => {
1871
+ for (const t of file.topics) {
1872
+ upsertTopic.run(t.slug, t.title, t.description);
1873
+ clearParents.run(t.slug);
1874
+ for (const parent of t.parents) {
1875
+ if (parent === t.slug) continue;
1876
+ insertParent.run(t.slug, parent);
1877
+ }
1878
+ }
1879
+ const existing = db.prepare("SELECT slug FROM topics").all();
1880
+ const deleteTopic = db.prepare("DELETE FROM topics WHERE slug = ?");
1881
+ const deleteEdgesByChild = db.prepare(
1882
+ "DELETE FROM topic_parents WHERE child_slug = ?"
1883
+ );
1884
+ const deleteEdgesByParent = db.prepare(
1885
+ "DELETE FROM topic_parents WHERE parent_slug = ?"
1886
+ );
1887
+ for (const r of existing) {
1888
+ if (declared.has(r.slug)) continue;
1889
+ deleteEdgesByChild.run(r.slug);
1890
+ deleteEdgesByParent.run(r.slug);
1891
+ deleteTopic.run(r.slug);
1892
+ }
1893
+ });
1894
+ apply();
1895
+ }
1896
+
1897
+ // src/indexer/resolveWiki.ts
1898
+ import { existsSync as existsSync9 } from "fs";
1899
+ import { join as join7 } from "path";
1900
+ async function resolveWikiRoot(params) {
1901
+ if (params.wiki !== void 0) {
1902
+ const entry = await findEntry({ name: params.wiki });
1903
+ if (entry === null) {
1904
+ throw new Error(`no registered wiki named "${params.wiki}"`);
1905
+ }
1906
+ if (!existsSync9(join7(entry.path, ".almanac"))) {
1907
+ throw new Error(
1908
+ `wiki "${params.wiki}" path is unreachable (${entry.path})`
1909
+ );
1910
+ }
1911
+ return entry.path;
1912
+ }
1913
+ const nearest = findNearestAlmanacDir(params.cwd);
1914
+ if (nearest === null) {
1915
+ throw new Error(
1916
+ "no .almanac/ found in this directory or any parent; run `almanac init` first"
1917
+ );
1918
+ }
1919
+ return nearest;
1920
+ }
1921
+
1922
+ // src/topics/dag.ts
1923
+ var DAG_DEPTH_CAP = 32;
1924
+ function ancestorsInFile(file, slug) {
1925
+ const parentsOf = /* @__PURE__ */ new Map();
1926
+ for (const t of file.topics) {
1927
+ parentsOf.set(t.slug, t.parents);
1928
+ }
1929
+ const ancestors = /* @__PURE__ */ new Set();
1930
+ let frontier = parentsOf.get(slug) ?? [];
1931
+ let depth = 0;
1932
+ while (frontier.length > 0 && depth < DAG_DEPTH_CAP) {
1933
+ const next = [];
1934
+ for (const node of frontier) {
1935
+ if (ancestors.has(node)) continue;
1936
+ ancestors.add(node);
1937
+ const ps = parentsOf.get(node);
1938
+ if (ps !== void 0) next.push(...ps);
1939
+ }
1940
+ frontier = next;
1941
+ depth += 1;
1942
+ }
1943
+ return ancestors;
1944
+ }
1945
+ function descendantsInDb(db, slug) {
1946
+ const rows = db.prepare(
1947
+ `WITH RECURSIVE desc(slug, depth) AS (
1948
+ SELECT child_slug, 1 FROM topic_parents WHERE parent_slug = ?
1949
+ UNION
1950
+ SELECT tp.child_slug, d.depth + 1
1951
+ FROM topic_parents tp
1952
+ JOIN desc d ON tp.parent_slug = d.slug
1953
+ WHERE d.depth < ?
1954
+ )
1955
+ SELECT DISTINCT slug FROM desc ORDER BY slug`
1956
+ ).all(slug, DAG_DEPTH_CAP).map((r) => r.slug);
1957
+ return rows;
1958
+ }
1959
+ function subtreeInDb(db, slug) {
1960
+ return [slug, ...descendantsInDb(db, slug)];
1961
+ }
1962
+
1963
+ // src/commands/health.ts
1964
+ var DEFAULT_STALE_SECONDS = 90 * 24 * 60 * 60;
1965
+ async function runHealth(options) {
1966
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
1967
+ await ensureFreshIndex({ repoRoot });
1968
+ const almanacDir = join8(repoRoot, ".almanac");
1969
+ const pagesDir = join8(almanacDir, "pages");
1970
+ const db = openIndex(join8(almanacDir, "index.db"));
1971
+ try {
1972
+ const staleSeconds = options.stale !== void 0 ? parseDuration(options.stale) : DEFAULT_STALE_SECONDS;
1973
+ const scope = resolveScope(db, options);
1974
+ const report = {
1975
+ orphans: findOrphans(db, scope),
1976
+ stale: findStale(db, scope, staleSeconds),
1977
+ dead_refs: await findDeadRefs(db, scope, repoRoot),
1978
+ broken_links: findBrokenLinks(db, scope),
1979
+ broken_xwiki: await findBrokenXwiki(db, scope),
1980
+ empty_topics: findEmptyTopics(db, scope),
1981
+ empty_pages: await findEmptyPages(db, scope, pagesDir),
1982
+ slug_collisions: await findSlugCollisions(pagesDir)
1983
+ };
1984
+ if (options.json === true) {
1985
+ return {
1986
+ stdout: `${JSON.stringify(report, null, 2)}
1987
+ `,
1988
+ stderr: "",
1989
+ exitCode: 0
1990
+ };
1991
+ }
1992
+ return {
1993
+ stdout: formatReport(report),
1994
+ stderr: "",
1995
+ exitCode: 0
1996
+ };
1997
+ } finally {
1998
+ db.close();
1999
+ }
2000
+ }
2001
+ function resolveScope(db, options) {
2002
+ let pages = null;
2003
+ let topics = null;
2004
+ if (options.topic !== void 0) {
2005
+ const rootSlug = toKebabCase(options.topic);
2006
+ if (rootSlug.length > 0) {
2007
+ const subtree = subtreeInDb(db, rootSlug);
2008
+ topics = new Set(subtree);
2009
+ const placeholders = subtree.map(() => "?").join(", ");
2010
+ const rows = db.prepare(
2011
+ `SELECT DISTINCT page_slug FROM page_topics
2012
+ WHERE topic_slug IN (${placeholders})`
2013
+ ).all(...subtree);
2014
+ pages = new Set(rows.map((r) => r.page_slug));
2015
+ }
2016
+ }
2017
+ if (options.stdin === true && options.stdinInput !== void 0) {
2018
+ const stdinPages = /* @__PURE__ */ new Set();
2019
+ for (const line of options.stdinInput.split(/\r?\n/)) {
2020
+ const s = line.trim();
2021
+ if (s.length > 0) stdinPages.add(s);
2022
+ }
2023
+ if (pages === null) pages = stdinPages;
2024
+ else {
2025
+ const out = /* @__PURE__ */ new Set();
2026
+ for (const s of stdinPages) if (pages.has(s)) out.add(s);
2027
+ pages = out;
2028
+ }
2029
+ }
2030
+ return { pages, topics };
2031
+ }
2032
+ function inPageScope(scope, slug) {
2033
+ if (scope.pages === null) return true;
2034
+ return scope.pages.has(slug);
2035
+ }
2036
+ function findOrphans(db, scope) {
2037
+ const rows = db.prepare(
2038
+ `SELECT p.slug FROM pages p
2039
+ WHERE p.archived_at IS NULL
2040
+ AND NOT EXISTS (
2041
+ SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug
2042
+ )
2043
+ ORDER BY p.slug`
2044
+ ).all();
2045
+ return rows.filter((r) => inPageScope(scope, r.slug));
2046
+ }
2047
+ function findStale(db, scope, staleSeconds) {
2048
+ const now = Math.floor(Date.now() / 1e3);
2049
+ const threshold = now - staleSeconds;
2050
+ const rows = db.prepare(
2051
+ `SELECT slug, updated_at FROM pages
2052
+ WHERE archived_at IS NULL AND updated_at < ?
2053
+ ORDER BY updated_at ASC`
2054
+ ).all(threshold);
2055
+ return rows.filter((r) => inPageScope(scope, r.slug)).map((r) => ({
2056
+ slug: r.slug,
2057
+ days_since_update: Math.floor((now - r.updated_at) / (60 * 60 * 24))
2058
+ }));
2059
+ }
2060
+ async function findDeadRefs(db, scope, repoRoot) {
2061
+ const rows = db.prepare(
2062
+ `SELECT p.slug, r.path, r.original_path, r.is_dir
2063
+ FROM file_refs r
2064
+ JOIN pages p ON p.slug = r.page_slug
2065
+ WHERE p.archived_at IS NULL
2066
+ ORDER BY p.slug, r.path`
2067
+ ).all();
2068
+ const out = [];
2069
+ for (const r of rows) {
2070
+ if (!inPageScope(scope, r.slug)) continue;
2071
+ const abs = join8(repoRoot, r.original_path);
2072
+ if (!existsSync10(abs)) {
2073
+ out.push({ slug: r.slug, path: r.original_path });
2074
+ }
2075
+ }
2076
+ return out;
2077
+ }
2078
+ function findBrokenLinks(db, scope) {
2079
+ const rows = db.prepare(
2080
+ `SELECT w.source_slug, w.target_slug
2081
+ FROM wikilinks w
2082
+ JOIN pages src ON src.slug = w.source_slug
2083
+ LEFT JOIN pages tgt ON tgt.slug = w.target_slug
2084
+ WHERE tgt.slug IS NULL AND src.archived_at IS NULL
2085
+ ORDER BY w.source_slug, w.target_slug`
2086
+ ).all();
2087
+ return rows.filter((r) => inPageScope(scope, r.source_slug));
2088
+ }
2089
+ async function findBrokenXwiki(db, scope) {
2090
+ const rows = db.prepare(
2091
+ // Same archived-source filter as `findBrokenLinks`. Retired pages
2092
+ // shouldn't spam the report with links to wikis that may have
2093
+ // been intentionally retired too.
2094
+ `SELECT x.source_slug, x.target_wiki, x.target_slug
2095
+ FROM cross_wiki_links x
2096
+ JOIN pages src ON src.slug = x.source_slug
2097
+ WHERE src.archived_at IS NULL
2098
+ ORDER BY x.source_slug, x.target_wiki, x.target_slug`
2099
+ ).all();
2100
+ const out = [];
2101
+ const reachableCache = /* @__PURE__ */ new Map();
2102
+ for (const r of rows) {
2103
+ if (!inPageScope(scope, r.source_slug)) continue;
2104
+ let ok = reachableCache.get(r.target_wiki);
2105
+ if (ok === void 0) {
2106
+ const entry = await findEntry({ name: r.target_wiki });
2107
+ ok = entry !== null && existsSync10(join8(entry.path, ".almanac"));
2108
+ reachableCache.set(r.target_wiki, ok);
2109
+ }
2110
+ if (!ok) {
2111
+ out.push({
2112
+ source_slug: r.source_slug,
2113
+ target_wiki: r.target_wiki,
2114
+ target_slug: r.target_slug
2115
+ });
2116
+ }
2117
+ }
2118
+ return out;
2119
+ }
2120
+ function findEmptyTopics(db, scope) {
2121
+ const rows = db.prepare(
2122
+ `SELECT t.slug FROM topics t
2123
+ WHERE NOT EXISTS (
2124
+ SELECT 1 FROM page_topics pt WHERE pt.topic_slug = t.slug
2125
+ )
2126
+ ORDER BY t.slug`
2127
+ ).all();
2128
+ if (scope.topics === null) return rows;
2129
+ return rows.filter((r) => scope.topics.has(r.slug));
2130
+ }
2131
+ async function findEmptyPages(db, scope, pagesDir) {
2132
+ const rows = db.prepare(
2133
+ `SELECT slug, file_path FROM pages
2134
+ WHERE archived_at IS NULL
2135
+ ORDER BY slug`
2136
+ ).all();
2137
+ const out = [];
2138
+ for (const r of rows) {
2139
+ if (!inPageScope(scope, r.slug)) continue;
2140
+ let raw;
2141
+ try {
2142
+ raw = await readFile8(r.file_path, "utf8");
2143
+ } catch {
2144
+ continue;
2145
+ }
2146
+ const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
2147
+ const body = m !== null ? m[1] ?? "" : raw;
2148
+ void pagesDir;
2149
+ const hasSubstance = body.split(/\r?\n/).some((l) => {
2150
+ const t = l.trim();
2151
+ if (t.length === 0) return false;
2152
+ if (t.startsWith("#")) return false;
2153
+ return true;
2154
+ });
2155
+ if (!hasSubstance) {
2156
+ out.push({ slug: r.slug });
2157
+ }
2158
+ }
2159
+ return out;
2160
+ }
2161
+ async function findSlugCollisions(pagesDir) {
2162
+ if (!existsSync10(pagesDir)) return [];
2163
+ const files = await fg2("**/*.md", {
2164
+ cwd: pagesDir,
2165
+ absolute: false,
2166
+ onlyFiles: true,
2167
+ caseSensitiveMatch: true
2168
+ });
2169
+ const bySlug = /* @__PURE__ */ new Map();
2170
+ for (const rel of files) {
2171
+ const slug = toKebabCase(basename4(rel, ".md"));
2172
+ if (slug.length === 0) continue;
2173
+ const list = bySlug.get(slug) ?? [];
2174
+ list.push(rel);
2175
+ bySlug.set(slug, list);
2176
+ }
2177
+ const out = [];
2178
+ for (const [slug, paths] of bySlug.entries()) {
2179
+ if (paths.length > 1) {
2180
+ out.push({ slug, paths: paths.sort() });
2181
+ }
2182
+ }
2183
+ out.sort((a, b) => a.slug.localeCompare(b.slug));
2184
+ return out;
2185
+ }
2186
+ function formatReport(r) {
2187
+ const sections = [];
2188
+ sections.push(
2189
+ section(
2190
+ "orphans",
2191
+ r.orphans.length,
2192
+ r.orphans.map((o) => ` ${o.slug}`)
2193
+ )
2194
+ );
2195
+ sections.push(
2196
+ section(
2197
+ "stale",
2198
+ r.stale.length,
2199
+ r.stale.map((s) => ` ${s.slug} (${s.days_since_update} days)`)
2200
+ )
2201
+ );
2202
+ sections.push(
2203
+ section(
2204
+ "dead-refs",
2205
+ r.dead_refs.length,
2206
+ r.dead_refs.map((d) => ` ${d.slug} references ${d.path} (missing)`)
2207
+ )
2208
+ );
2209
+ sections.push(
2210
+ section(
2211
+ "broken-links",
2212
+ r.broken_links.length,
2213
+ r.broken_links.map(
2214
+ (b) => ` ${b.source_slug} \u2192 ${b.target_slug} (target does not exist)`
2215
+ )
2216
+ )
2217
+ );
2218
+ sections.push(
2219
+ section(
2220
+ "broken-xwiki",
2221
+ r.broken_xwiki.length,
2222
+ r.broken_xwiki.map(
2223
+ (b) => ` ${b.source_slug} \u2192 ${b.target_wiki}:${b.target_slug} (wiki unregistered or unreachable)`
2224
+ )
2225
+ )
2226
+ );
2227
+ sections.push(
2228
+ section(
2229
+ "empty-topics",
2230
+ r.empty_topics.length,
2231
+ r.empty_topics.map((e) => ` ${e.slug}`)
2232
+ )
2233
+ );
2234
+ sections.push(
2235
+ section(
2236
+ "empty-pages",
2237
+ r.empty_pages.length,
2238
+ r.empty_pages.map((e) => ` ${e.slug}`)
2239
+ )
2240
+ );
2241
+ sections.push(
2242
+ section(
2243
+ "slug-collisions",
2244
+ r.slug_collisions.length,
2245
+ r.slug_collisions.map((c) => ` ${c.slug}: ${c.paths.join(", ")}`)
2246
+ )
2247
+ );
2248
+ return `${sections.join("\n\n")}
2249
+ `;
2250
+ }
2251
+ function section(label, count, lines) {
2252
+ if (count === 0) return `${label} (0): (ok)`;
2253
+ return `${label} (${count}):
2254
+ ${lines.join("\n")}`;
2255
+ }
2256
+
2257
+ // src/commands/info.ts
2258
+ import { join as join9 } from "path";
2259
+ async function runInfo(options) {
2260
+ const repoRoot = await resolveWikiRoot({
2261
+ cwd: options.cwd,
2262
+ wiki: options.wiki
2263
+ });
2264
+ await ensureFreshIndex({ repoRoot });
2265
+ const dbPath = join9(repoRoot, ".almanac", "index.db");
2266
+ const db = openIndex(dbPath);
2267
+ try {
2268
+ const slugs = collectSlugs(options);
2269
+ if (slugs.length === 0) {
2270
+ return {
2271
+ stdout: "",
2272
+ stderr: "almanac: info requires a slug (or --stdin)\n",
2273
+ exitCode: 1
2274
+ };
2275
+ }
2276
+ const records = [];
2277
+ const missing = [];
2278
+ for (const slug of slugs) {
2279
+ const rec = fetchInfo(db, slug);
2280
+ if (rec === null) {
2281
+ missing.push(slug);
2282
+ continue;
2283
+ }
2284
+ records.push(rec);
2285
+ }
2286
+ const bulk = options.stdin === true;
2287
+ const jsonOut = options.json === true || bulk;
2288
+ let stdout;
2289
+ if (jsonOut) {
2290
+ if (bulk) {
2291
+ stdout = `${JSON.stringify(records, null, 2)}
2292
+ `;
2293
+ } else {
2294
+ const only = records[0] ?? null;
2295
+ stdout = `${JSON.stringify(only, null, 2)}
2296
+ `;
2297
+ }
2298
+ } else {
2299
+ stdout = records.map(formatHumanReadable).join("\n");
2300
+ }
2301
+ const stderr = missing.map((s) => `almanac: no such page "${s}"
2302
+ `).join("");
2303
+ return {
2304
+ stdout,
2305
+ stderr,
2306
+ exitCode: missing.length > 0 ? 1 : 0
2307
+ };
2308
+ } finally {
2309
+ db.close();
2310
+ }
2311
+ }
2312
+ function fetchInfo(db, slug) {
2313
+ const pageRow = db.prepare(
2314
+ "SELECT slug, title, file_path, updated_at, archived_at, superseded_by FROM pages WHERE slug = ?"
2315
+ ).get(slug);
2316
+ if (pageRow === void 0) return null;
2317
+ const topics = db.prepare(
2318
+ "SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
2319
+ ).all(slug).map((r) => r.topic_slug);
2320
+ const refs = db.prepare(
2321
+ // Display the author's casing (`original_path`), not the
2322
+ // lowercased lookup form. The lowercased `path` column is the
2323
+ // query key for `--mentions`; it's not a user-facing string.
2324
+ "SELECT original_path, is_dir FROM file_refs WHERE page_slug = ? ORDER BY original_path"
2325
+ ).all(slug).map((r) => ({ path: r.original_path, is_dir: r.is_dir === 1 }));
2326
+ const linksOut = db.prepare(
2327
+ "SELECT target_slug FROM wikilinks WHERE source_slug = ? ORDER BY target_slug"
2328
+ ).all(slug).map((r) => r.target_slug);
2329
+ const linksIn = db.prepare(
2330
+ "SELECT source_slug FROM wikilinks WHERE target_slug = ? ORDER BY source_slug"
2331
+ ).all(slug).map((r) => r.source_slug);
2332
+ const xwiki = db.prepare(
2333
+ "SELECT target_wiki, target_slug FROM cross_wiki_links WHERE source_slug = ? ORDER BY target_wiki, target_slug"
2334
+ ).all(slug).map((r) => ({ wiki: r.target_wiki, target: r.target_slug }));
2335
+ const supersedesRows = db.prepare(
2336
+ "SELECT slug FROM pages WHERE superseded_by = ? ORDER BY slug"
2337
+ ).all(slug).map((r) => r.slug);
2338
+ return {
2339
+ slug: pageRow.slug,
2340
+ title: pageRow.title,
2341
+ file_path: pageRow.file_path,
2342
+ updated_at: pageRow.updated_at,
2343
+ archived_at: pageRow.archived_at,
2344
+ superseded_by: pageRow.superseded_by,
2345
+ supersedes: supersedesRows,
2346
+ topics,
2347
+ file_refs: refs,
2348
+ wikilinks_out: linksOut,
2349
+ wikilinks_in: linksIn,
2350
+ cross_wiki_links: xwiki
2351
+ };
2352
+ }
2353
+ function collectSlugs(options) {
2354
+ if (options.stdin === true && options.stdinInput !== void 0) {
2355
+ return options.stdinInput.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
2356
+ }
2357
+ if (options.slug !== void 0 && options.slug.length > 0) {
2358
+ return [options.slug];
2359
+ }
2360
+ return [];
2361
+ }
2362
+ function formatHumanReadable(rec) {
2363
+ const lines = [];
2364
+ lines.push(`slug: ${rec.slug}`);
2365
+ lines.push(`title: ${rec.title ?? "\u2014"}`);
2366
+ lines.push(`file: ${rec.file_path}`);
2367
+ lines.push(`updated_at: ${new Date(rec.updated_at * 1e3).toISOString()}`);
2368
+ if (rec.archived_at !== null) {
2369
+ lines.push(
2370
+ `archived_at: ${new Date(rec.archived_at * 1e3).toISOString()}`
2371
+ );
2372
+ }
2373
+ if (rec.superseded_by !== null) {
2374
+ lines.push(`superseded_by: ${rec.superseded_by}`);
2375
+ }
2376
+ if (rec.supersedes.length > 0) {
2377
+ lines.push(`supersedes: ${rec.supersedes.join(", ")}`);
2378
+ }
2379
+ lines.push(`topics: ${rec.topics.length > 0 ? rec.topics.join(", ") : "\u2014"}`);
2380
+ lines.push("file_refs:");
2381
+ if (rec.file_refs.length === 0) {
2382
+ lines.push(" \u2014");
2383
+ } else {
2384
+ for (const r of rec.file_refs) {
2385
+ lines.push(` ${r.path}${r.is_dir ? " (dir)" : ""}`);
2386
+ }
2387
+ }
2388
+ lines.push("wikilinks_out:");
2389
+ if (rec.wikilinks_out.length === 0) lines.push(" \u2014");
2390
+ else for (const t of rec.wikilinks_out) lines.push(` ${t}`);
2391
+ lines.push("wikilinks_in:");
2392
+ if (rec.wikilinks_in.length === 0) lines.push(" \u2014");
2393
+ else for (const s of rec.wikilinks_in) lines.push(` ${s}`);
2394
+ if (rec.cross_wiki_links.length > 0) {
2395
+ lines.push("cross_wiki_links:");
2396
+ for (const x of rec.cross_wiki_links) {
2397
+ lines.push(` ${x.wiki}:${x.target}`);
2398
+ }
2399
+ }
2400
+ return `${lines.join("\n")}
2401
+ `;
2402
+ }
2403
+
2404
+ // src/commands/list.ts
2405
+ import { existsSync as existsSync11 } from "fs";
2406
+ async function listWikis(options) {
2407
+ if (options.drop !== void 0) {
2408
+ return handleDrop(options.drop);
2409
+ }
2410
+ const entries = await readRegistry();
2411
+ const reachable = entries.filter((e) => isReachable(e));
2412
+ if (options.json === true) {
2413
+ return { stdout: `${JSON.stringify(reachable, null, 2)}
2414
+ `, exitCode: 0 };
2415
+ }
2416
+ return { stdout: formatPretty(reachable), exitCode: 0 };
2417
+ }
2418
+ async function handleDrop(name) {
2419
+ const removed = await dropEntry(name);
2420
+ if (removed === null) {
2421
+ return {
2422
+ stdout: `no registry entry named "${name}"
2423
+ `,
2424
+ exitCode: 1
2425
+ };
2426
+ }
2427
+ return {
2428
+ stdout: `removed "${removed.name}" (${removed.path})
2429
+ `,
2430
+ exitCode: 0
2431
+ };
2432
+ }
2433
+ function isReachable(entry) {
2434
+ if (entry.path.length === 0) return false;
2435
+ return existsSync11(entry.path);
2436
+ }
2437
+ function formatPretty(entries) {
2438
+ if (entries.length === 0) {
2439
+ return "no wikis registered. run `almanac init` in a repo to create one.\n";
2440
+ }
2441
+ const nameWidth = Math.min(
2442
+ 30,
2443
+ entries.reduce((w, e) => Math.max(w, e.name.length), 0)
2444
+ );
2445
+ const lines = [];
2446
+ for (const entry of entries) {
2447
+ const name = entry.name.padEnd(nameWidth);
2448
+ const desc = entry.description.length > 0 ? entry.description : "\u2014";
2449
+ lines.push(`${name} ${desc}`);
2450
+ lines.push(`${" ".repeat(nameWidth)} ${entry.path}`);
2451
+ }
2452
+ return `${lines.join("\n")}
2453
+ `;
2454
+ }
2455
+
2456
+ // src/commands/path.ts
2457
+ import { join as join10 } from "path";
2458
+ async function runPath(options) {
2459
+ const repoRoot = await resolveWikiRoot({
2460
+ cwd: options.cwd,
2461
+ wiki: options.wiki
2462
+ });
2463
+ await ensureFreshIndex({ repoRoot });
2464
+ const dbPath = join10(repoRoot, ".almanac", "index.db");
2465
+ const db = openIndex(dbPath);
2466
+ try {
2467
+ const slugs = collectSlugs2(options);
2468
+ if (slugs.length === 0) {
2469
+ return {
2470
+ stdout: "",
2471
+ stderr: "almanac: path requires a slug (or --stdin)\n",
2472
+ exitCode: 1
2473
+ };
2474
+ }
2475
+ const stmt = db.prepare(
2476
+ "SELECT file_path FROM pages WHERE slug = ?"
2477
+ );
2478
+ const resolved = [];
2479
+ const missing = [];
2480
+ for (const slug of slugs) {
2481
+ const row = stmt.get(slug);
2482
+ if (row === void 0) {
2483
+ missing.push(slug);
2484
+ continue;
2485
+ }
2486
+ resolved.push(row.file_path);
2487
+ }
2488
+ const stdout = resolved.length > 0 ? `${resolved.join("\n")}
2489
+ ` : "";
2490
+ const stderr = missing.map((s) => `almanac: no such page "${s}"
2491
+ `).join("");
2492
+ return {
2493
+ stdout,
2494
+ stderr,
2495
+ exitCode: missing.length > 0 ? 1 : 0
2496
+ };
2497
+ } finally {
2498
+ db.close();
2499
+ }
2500
+ }
2501
+ function collectSlugs2(options) {
2502
+ if (options.stdin === true && options.stdinInput !== void 0) {
2503
+ return options.stdinInput.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
2504
+ }
2505
+ if (options.slug !== void 0 && options.slug.length > 0) {
2506
+ return [options.slug];
2507
+ }
2508
+ return [];
2509
+ }
2510
+
2511
+ // src/commands/reindex.ts
2512
+ async function runReindex(options) {
2513
+ const repoRoot = await resolveWikiRoot({
2514
+ cwd: options.cwd,
2515
+ wiki: options.wiki
2516
+ });
2517
+ const result = await runIndexer({ repoRoot });
2518
+ const skipSuffix = result.filesSkipped > 0 ? `; ${result.filesSkipped} skipped` : "";
2519
+ const stdout = `reindexed: ${result.pagesIndexed} page${result.pagesIndexed === 1 ? "" : "s"} (${result.changed} updated, ${result.removed} removed${skipSuffix})
2520
+ `;
2521
+ return { result, stdout, exitCode: 0 };
2522
+ }
2523
+
2524
+ // src/commands/search.ts
2525
+ import { join as join11 } from "path";
2526
+ async function runSearch(options) {
2527
+ const repoRoot = await resolveWikiRoot({
2528
+ cwd: options.cwd,
2529
+ wiki: options.wiki
2530
+ });
2531
+ await ensureFreshIndex({ repoRoot });
2532
+ const dbPath = join11(repoRoot, ".almanac", "index.db");
2533
+ const db = openIndex(dbPath);
2534
+ try {
2535
+ const rows = executeQuery(db, options);
2536
+ const limited = options.limit !== void 0 && options.limit >= 0 ? rows.slice(0, options.limit) : rows;
2537
+ const stdout = formatResults(limited, options);
2538
+ const stderr = buildStderr(limited, options);
2539
+ return { stdout, stderr, exitCode: 0 };
2540
+ } finally {
2541
+ db.close();
2542
+ }
2543
+ }
2544
+ function executeQuery(db, options) {
2545
+ const whereClauses = [];
2546
+ const params = [];
2547
+ if (options.archived === true) {
2548
+ whereClauses.push("p.archived_at IS NOT NULL");
2549
+ } else if (options.includeArchive !== true) {
2550
+ whereClauses.push("p.archived_at IS NULL");
2551
+ }
2552
+ for (const rawTopic of options.topics) {
2553
+ const topicSlug = slugForTopic(rawTopic);
2554
+ if (topicSlug.length === 0) continue;
2555
+ whereClauses.push(
2556
+ "EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug AND pt.topic_slug = ?)"
2557
+ );
2558
+ params.push(topicSlug);
2559
+ }
2560
+ if (options.mentions !== void 0 && options.mentions.length > 0) {
2561
+ const isDir = looksLikeDir(options.mentions);
2562
+ const norm = normalizePath(options.mentions, isDir);
2563
+ if (isDir) {
2564
+ const escaped = escapeGlobMeta(norm);
2565
+ whereClauses.push(
2566
+ `EXISTS (
2567
+ SELECT 1 FROM file_refs r
2568
+ WHERE r.page_slug = p.slug
2569
+ AND (r.path = ? OR r.path GLOB ?)
2570
+ )`
2571
+ );
2572
+ params.push(norm, `${escaped}*`);
2573
+ } else {
2574
+ const prefixes = parentFolderPrefixes(norm);
2575
+ if (prefixes.length === 0) {
2576
+ whereClauses.push(
2577
+ `EXISTS (
2578
+ SELECT 1 FROM file_refs r
2579
+ WHERE r.page_slug = p.slug AND r.path = ?
2580
+ )`
2581
+ );
2582
+ params.push(norm);
2583
+ } else {
2584
+ const placeholders = prefixes.map(() => "?").join(", ");
2585
+ whereClauses.push(
2586
+ `EXISTS (
2587
+ SELECT 1 FROM file_refs r
2588
+ WHERE r.page_slug = p.slug
2589
+ AND (
2590
+ r.path = ?
2591
+ OR (r.is_dir = 1 AND r.path IN (${placeholders}))
2592
+ )
2593
+ )`
2594
+ );
2595
+ params.push(norm, ...prefixes);
2596
+ }
2597
+ }
2598
+ }
2599
+ const now = Math.floor(Date.now() / 1e3);
2600
+ if (options.since !== void 0) {
2601
+ const seconds = parseDuration(options.since);
2602
+ whereClauses.push("p.updated_at >= ?");
2603
+ params.push(now - seconds);
2604
+ }
2605
+ if (options.stale !== void 0) {
2606
+ const seconds = parseDuration(options.stale);
2607
+ whereClauses.push("p.updated_at < ?");
2608
+ params.push(now - seconds);
2609
+ }
2610
+ if (options.orphan === true) {
2611
+ whereClauses.push(
2612
+ "NOT EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug)"
2613
+ );
2614
+ }
2615
+ let sql;
2616
+ if (options.query !== void 0 && options.query.trim().length > 0) {
2617
+ const ftsExpr = buildFtsQuery(options.query);
2618
+ sql = `
2619
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
2620
+ FROM pages p
2621
+ JOIN fts_pages f ON f.slug = p.slug
2622
+ WHERE fts_pages MATCH ?
2623
+ ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
2624
+ ORDER BY f.rank ASC, p.updated_at DESC, p.slug ASC
2625
+ `;
2626
+ params.unshift(ftsExpr);
2627
+ } else {
2628
+ sql = buildSql(whereClauses);
2629
+ }
2630
+ const rows = db.prepare(sql).all(...params);
2631
+ const topicStmt = db.prepare(
2632
+ "SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
2633
+ );
2634
+ const out = rows.map((row) => ({
2635
+ slug: row.slug,
2636
+ title: row.title,
2637
+ updated_at: row.updated_at,
2638
+ archived_at: row.archived_at,
2639
+ superseded_by: row.superseded_by,
2640
+ topics: topicStmt.all(row.slug).map((t) => t.topic_slug)
2641
+ }));
2642
+ return out;
2643
+ }
2644
+ function buildSql(whereClauses) {
2645
+ const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
2646
+ return `
2647
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
2648
+ FROM pages p
2649
+ ${where}
2650
+ ORDER BY p.updated_at DESC, p.slug ASC
2651
+ `;
2652
+ }
2653
+ function buildFtsQuery(raw) {
2654
+ const trimmed = raw.trim();
2655
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
2656
+ const inner = trimmed.slice(1, -1).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
2657
+ if (inner.length === 0) return '""';
2658
+ return `"${inner}"`;
2659
+ }
2660
+ const tokens = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
2661
+ if (tokens.length === 0) return '""';
2662
+ return tokens.map((t) => `${t}*`).join(" AND ");
2663
+ }
2664
+ function slugForTopic(raw) {
2665
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2666
+ }
2667
+ function parentFolderPrefixes(filePath) {
2668
+ const out = [];
2669
+ let cursor = 0;
2670
+ while (true) {
2671
+ const next = filePath.indexOf("/", cursor);
2672
+ if (next === -1) break;
2673
+ out.push(filePath.slice(0, next + 1));
2674
+ cursor = next + 1;
2675
+ }
2676
+ return out;
2677
+ }
2678
+ function escapeGlobMeta(s) {
2679
+ return s.replace(/[\*\?\[]/g, (ch) => `[${ch}]`);
2680
+ }
2681
+ function formatResults(rows, options) {
2682
+ if (options.json === true) {
2683
+ return `${JSON.stringify(rows, null, 2)}
2684
+ `;
2685
+ }
2686
+ if (rows.length === 0) return "";
2687
+ return `${rows.map((r) => r.slug).join("\n")}
2688
+ `;
2689
+ }
2690
+ function buildStderr(rows, options) {
2691
+ if (options.json === true) return "";
2692
+ if (options.limit !== void 0) return "";
2693
+ if (rows.length > 50) {
2694
+ return `almanac: ${rows.length} results \u2014 consider --limit or a narrower query
2695
+ `;
2696
+ }
2697
+ return "";
2698
+ }
2699
+
2700
+ // src/commands/show.ts
2701
+ import { readFile as readFile9 } from "fs/promises";
2702
+ import { join as join12 } from "path";
2703
+ async function runShow(options) {
2704
+ const repoRoot = await resolveWikiRoot({
2705
+ cwd: options.cwd,
2706
+ wiki: options.wiki
2707
+ });
2708
+ await ensureFreshIndex({ repoRoot });
2709
+ const dbPath = join12(repoRoot, ".almanac", "index.db");
2710
+ const db = openIndex(dbPath);
2711
+ try {
2712
+ const slugs = collectSlugs3(options);
2713
+ if (slugs.length === 0) {
2714
+ return {
2715
+ stdout: "",
2716
+ stderr: "almanac: show requires a slug (or --stdin)\n",
2717
+ exitCode: 1
2718
+ };
2719
+ }
2720
+ const stmt = db.prepare(
2721
+ "SELECT file_path FROM pages WHERE slug = ?"
2722
+ );
2723
+ const records = [];
2724
+ const missing = [];
2725
+ for (const slug of slugs) {
2726
+ const row = stmt.get(slug);
2727
+ if (row === void 0) {
2728
+ missing.push(slug);
2729
+ continue;
2730
+ }
2731
+ try {
2732
+ records.push({ slug, content: await readFile9(row.file_path, "utf8") });
2733
+ } catch (err) {
2734
+ const message = err instanceof Error ? err.message : String(err);
2735
+ missing.push(`${slug} (${message})`);
2736
+ }
2737
+ }
2738
+ const bulk = options.stdin === true;
2739
+ let stdout;
2740
+ if (bulk) {
2741
+ stdout = records.map((r) => JSON.stringify(r)).join("\n");
2742
+ if (stdout.length > 0) stdout += "\n";
2743
+ } else {
2744
+ stdout = records.map((r) => r.content).join("");
2745
+ }
2746
+ const stderr = missing.map((s) => `almanac: no such page "${s}"
2747
+ `).join("");
2748
+ return {
2749
+ stdout,
2750
+ stderr,
2751
+ exitCode: missing.length > 0 ? 1 : 0
2752
+ };
2753
+ } finally {
2754
+ db.close();
2755
+ }
2756
+ }
2757
+ function collectSlugs3(options) {
2758
+ if (options.stdin === true && options.stdinInput !== void 0) {
2759
+ return options.stdinInput.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
2760
+ }
2761
+ if (options.slug !== void 0 && options.slug.length > 0) {
2762
+ return [options.slug];
2763
+ }
2764
+ return [];
2765
+ }
2766
+
2767
+ // src/topics/frontmatterRewrite.ts
2768
+ import { readFile as readFile10, rename as rename4, writeFile as writeFile5 } from "fs/promises";
2769
+ import yaml3 from "js-yaml";
2770
+ async function rewritePageTopics(filePath, transform) {
2771
+ const raw = await readFile10(filePath, "utf8");
2772
+ const { before, after, output, changed } = applyTopicsTransform(
2773
+ raw,
2774
+ transform
2775
+ );
2776
+ if (changed) {
2777
+ const tmp = `${filePath}.tmp`;
2778
+ await writeFile5(tmp, output, "utf8");
2779
+ await rename4(tmp, filePath);
2780
+ }
2781
+ return { before, after, changed };
2782
+ }
2783
+ function applyTopicsTransform(raw, transform) {
2784
+ const parsed = splitFrontmatter(raw);
2785
+ if (parsed === null) {
2786
+ const next = dedupeSlugs(transform([]));
2787
+ if (next.length === 0) {
2788
+ return { before: [], after: [], output: raw, changed: false };
2789
+ }
2790
+ const fm = `---
2791
+ topics: ${flowList(next)}
2792
+ ---
2793
+
2794
+ `;
2795
+ return {
2796
+ before: [],
2797
+ after: next,
2798
+ output: `${fm}${raw}`,
2799
+ changed: true
2800
+ };
2801
+ }
2802
+ const { opener, fmLines, closer, body, eol } = parsed;
2803
+ const { before, existingRange } = readTopicsFromLines(fmLines);
2804
+ const beforeDeduped = dedupeSlugs(before);
2805
+ const after = dedupeSlugs(transform(beforeDeduped));
2806
+ if (arraysEqual(beforeDeduped, after)) {
2807
+ return { before: beforeDeduped, after, output: raw, changed: false };
2808
+ }
2809
+ let nextFmLines;
2810
+ if (existingRange === null) {
2811
+ if (after.length === 0) {
2812
+ return { before: beforeDeduped, after, output: raw, changed: false };
2813
+ }
2814
+ nextFmLines = [...fmLines, `topics: ${flowList(after)}`];
2815
+ } else {
2816
+ const replacement = after.length === 0 ? null : `topics: ${flowList(after)}`;
2817
+ const preservedTail = replacement === null ? [] : existingRange.preserved;
2818
+ nextFmLines = [
2819
+ ...fmLines.slice(0, existingRange.start),
2820
+ ...replacement === null ? [] : [replacement],
2821
+ ...preservedTail,
2822
+ ...fmLines.slice(existingRange.end)
2823
+ ];
2824
+ }
2825
+ const fmBlock = nextFmLines.length === 0 ? "" : `${nextFmLines.join(eol)}${eol}`;
2826
+ const output = `${opener}${fmBlock}${closer}${body}`;
2827
+ return {
2828
+ before: beforeDeduped,
2829
+ after,
2830
+ output,
2831
+ changed: true
2832
+ };
2833
+ }
2834
+ function splitFrontmatter(raw) {
2835
+ if (!raw.startsWith("---")) return null;
2836
+ const openerMatch = raw.match(/^---(\r?\n)/);
2837
+ if (openerMatch === null) return null;
2838
+ const opener = `---${openerMatch[1] ?? "\n"}`;
2839
+ const rest = raw.slice(opener.length);
2840
+ let fenceIdx;
2841
+ if (rest.startsWith("---")) {
2842
+ fenceIdx = 0;
2843
+ } else {
2844
+ const m = rest.match(/\r?\n---(\r?\n|$)/);
2845
+ if (m === null || m.index === void 0) return null;
2846
+ const leadingNewlineLen = (m[0] ?? "").startsWith("\r\n") ? 2 : 1;
2847
+ fenceIdx = m.index + leadingNewlineLen;
2848
+ }
2849
+ const fmBlock = rest.slice(0, fenceIdx);
2850
+ const afterDashes = rest.slice(fenceIdx + 3);
2851
+ let closerTail = "";
2852
+ if (afterDashes.startsWith("\r\n")) {
2853
+ closerTail = "\r\n";
2854
+ } else if (afterDashes.startsWith("\n")) {
2855
+ closerTail = "\n";
2856
+ }
2857
+ const closer = `---${closerTail}`;
2858
+ const body = afterDashes.slice(closerTail.length);
2859
+ const fmLines = fmBlock.length === 0 ? [] : fmBlock.replace(/\r?\n$/, "").split(/\r?\n/);
2860
+ const eol = opener.endsWith("\r\n") || /\r\n/.test(fmBlock) ? "\r\n" : "\n";
2861
+ return { opener, fmLines, closer, body, eol };
2862
+ }
2863
+ function readTopicsFromLines(fmLines) {
2864
+ const keyLineIdx = findTopKey(fmLines, "topics");
2865
+ if (keyLineIdx === -1) {
2866
+ return { before: [], existingRange: null };
2867
+ }
2868
+ const keyLine = fmLines[keyLineIdx] ?? "";
2869
+ const colonIdx = keyLine.indexOf(":");
2870
+ const after = keyLine.slice(colonIdx + 1).trim();
2871
+ const afterNoComment = stripTrailingComment(after);
2872
+ if (afterNoComment.length === 0) {
2873
+ const values2 = [];
2874
+ const preserved = [];
2875
+ let i = keyLineIdx + 1;
2876
+ let endIdx = i;
2877
+ let pendingNonEntries = [];
2878
+ while (i < fmLines.length) {
2879
+ const line = fmLines[i] ?? "";
2880
+ const trimmed = line.trim();
2881
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
2882
+ pendingNonEntries.push(line);
2883
+ i += 1;
2884
+ continue;
2885
+ }
2886
+ const m = line.match(/^\s*-\s+(.*)$/);
2887
+ if (m === null) break;
2888
+ if (pendingNonEntries.length > 0) {
2889
+ preserved.push(...pendingNonEntries);
2890
+ pendingNonEntries = [];
2891
+ }
2892
+ const raw = stripTrailingComment((m[1] ?? "").trim());
2893
+ const parsed2 = parseScalar(raw);
2894
+ if (parsed2.length > 0) values2.push(parsed2);
2895
+ i += 1;
2896
+ endIdx = i;
2897
+ }
2898
+ return {
2899
+ before: values2,
2900
+ existingRange: { start: keyLineIdx, end: endIdx, preserved }
2901
+ };
2902
+ }
2903
+ let parsed;
2904
+ try {
2905
+ parsed = yaml3.load(afterNoComment);
2906
+ } catch {
2907
+ parsed = null;
2908
+ }
2909
+ const values = [];
2910
+ if (Array.isArray(parsed)) {
2911
+ for (const v of parsed) {
2912
+ if (typeof v === "string" && v.trim().length > 0) {
2913
+ values.push(v.trim());
2914
+ }
2915
+ }
2916
+ } else if (typeof parsed === "string" && parsed.trim().length > 0) {
2917
+ values.push(parsed.trim());
2918
+ }
2919
+ return {
2920
+ before: values,
2921
+ existingRange: { start: keyLineIdx, end: keyLineIdx + 1, preserved: [] }
2922
+ };
2923
+ }
2924
+ function findTopKey(fmLines, key) {
2925
+ const re = new RegExp(`^${escapeRegex(key)}\\s*:`);
2926
+ for (let i = 0; i < fmLines.length; i += 1) {
2927
+ if (re.test(fmLines[i] ?? "")) return i;
2928
+ }
2929
+ return -1;
2930
+ }
2931
+ function escapeRegex(s) {
2932
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2933
+ }
2934
+ function stripTrailingComment(s) {
2935
+ let inSingle = false;
2936
+ let inDouble = false;
2937
+ for (let i = 0; i < s.length; i += 1) {
2938
+ const ch = s[i];
2939
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
2940
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
2941
+ else if (ch === "#" && !inSingle && !inDouble) {
2942
+ return s.slice(0, i).trimEnd();
2943
+ }
2944
+ }
2945
+ return s;
2946
+ }
2947
+ function parseScalar(s) {
2948
+ if (s.length === 0) return s;
2949
+ if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
2950
+ return s.slice(1, -1);
2951
+ }
2952
+ if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'") {
2953
+ return s.slice(1, -1);
2954
+ }
2955
+ return s;
2956
+ }
2957
+ function flowList(items) {
2958
+ return `[${items.map((t) => formatScalar(t)).join(", ")}]`;
2959
+ }
2960
+ function formatScalar(s) {
2961
+ if (/^[a-z0-9][a-z0-9-]*$/.test(s)) return s;
2962
+ return yaml3.dump(s, { flowLevel: 0, lineWidth: Number.MAX_SAFE_INTEGER }).trimEnd();
2963
+ }
2964
+ function dedupeSlugs(list) {
2965
+ const seen = /* @__PURE__ */ new Set();
2966
+ const out = [];
2967
+ for (const raw of list) {
2968
+ const s = raw.trim();
2969
+ if (s.length === 0) continue;
2970
+ if (seen.has(s)) continue;
2971
+ seen.add(s);
2972
+ out.push(s);
2973
+ }
2974
+ return out;
2975
+ }
2976
+ function arraysEqual(a, b) {
2977
+ if (a.length !== b.length) return false;
2978
+ for (let i = 0; i < a.length; i += 1) {
2979
+ if (a[i] !== b[i]) return false;
2980
+ }
2981
+ return true;
2982
+ }
2983
+
2984
+ // src/topics/paths.ts
2985
+ import { join as join13 } from "path";
2986
+ function topicsYamlPath(repoRoot) {
2987
+ return join13(repoRoot, ".almanac", "topics.yaml");
2988
+ }
2989
+ function indexDbPath(repoRoot) {
2990
+ return join13(repoRoot, ".almanac", "index.db");
2991
+ }
2992
+
2993
+ // src/commands/tag.ts
2994
+ async function runTag(options) {
2995
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
2996
+ const topics = options.topics.map((t) => toKebabCase(t)).filter((t) => t.length > 0);
2997
+ if (topics.length === 0) {
2998
+ return {
2999
+ stdout: "",
3000
+ stderr: "almanac: tag requires at least one topic\n",
3001
+ exitCode: 1
3002
+ };
3003
+ }
3004
+ const pages = [];
3005
+ if (options.stdin === true) {
3006
+ if (options.stdinInput === void 0) {
3007
+ return {
3008
+ stdout: "",
3009
+ stderr: "almanac: tag --stdin called without stdin input\n",
3010
+ exitCode: 1
3011
+ };
3012
+ }
3013
+ for (const line of options.stdinInput.split(/\r?\n/)) {
3014
+ const s = line.trim();
3015
+ if (s.length > 0) pages.push(s);
3016
+ }
3017
+ } else if (options.page !== void 0 && options.page.length > 0) {
3018
+ pages.push(options.page);
3019
+ } else {
3020
+ return {
3021
+ stdout: "",
3022
+ stderr: "almanac: tag requires a page slug (or --stdin)\n",
3023
+ exitCode: 1
3024
+ };
3025
+ }
3026
+ await ensureFreshIndex({ repoRoot });
3027
+ const db = openIndex(indexDbPath(repoRoot));
3028
+ const stmt = db.prepare(
3029
+ "SELECT file_path FROM pages WHERE slug = ?"
3030
+ );
3031
+ const resolved = [];
3032
+ const missing = [];
3033
+ try {
3034
+ for (const page of pages) {
3035
+ const row = stmt.get(toKebabCase(page));
3036
+ if (row === void 0) {
3037
+ missing.push(page);
3038
+ } else {
3039
+ resolved.push({ page, filePath: row.file_path });
3040
+ }
3041
+ }
3042
+ } finally {
3043
+ db.close();
3044
+ }
3045
+ if (resolved.length === 0) {
3046
+ const stderr2 = missing.map((p) => `almanac: no such page "${p}"
3047
+ `).join("");
3048
+ return {
3049
+ stdout: "",
3050
+ stderr: stderr2,
3051
+ exitCode: 1
3052
+ };
3053
+ }
3054
+ const yamlPath = topicsYamlPath(repoRoot);
3055
+ const file = await loadTopicsFile(yamlPath);
3056
+ let fileChanged = false;
3057
+ for (const t of topics) {
3058
+ const before = file.topics.length;
3059
+ ensureTopic(file, t);
3060
+ if (file.topics.length > before) fileChanged = true;
3061
+ }
3062
+ if (fileChanged) {
3063
+ await writeTopicsFile(yamlPath, file);
3064
+ }
3065
+ const summary = [];
3066
+ let taggedPages = 0;
3067
+ for (const { page, filePath } of resolved) {
3068
+ const result = await rewritePageTopics(filePath, (current) => {
3069
+ const out = [...current];
3070
+ for (const t of topics) if (!current.includes(t)) out.push(t);
3071
+ return out;
3072
+ });
3073
+ if (result.changed) {
3074
+ taggedPages += 1;
3075
+ const added = result.after.filter((t) => !result.before.includes(t));
3076
+ summary.push(`tagged ${page}: ${added.join(", ")}`);
3077
+ } else {
3078
+ summary.push(
3079
+ `no change ${page} (already tagged with ${topics.join(", ")})`
3080
+ );
3081
+ }
3082
+ }
3083
+ if (taggedPages > 0 || fileChanged) {
3084
+ await runIndexer({ repoRoot });
3085
+ }
3086
+ const stderr = missing.map((p) => `almanac: no such page "${p}"
3087
+ `).join("");
3088
+ return {
3089
+ stdout: summary.length > 0 ? `${summary.join("\n")}
3090
+ ` : "",
3091
+ stderr,
3092
+ exitCode: missing.length > 0 ? 1 : 0
3093
+ };
3094
+ }
3095
+ async function runUntag(options) {
3096
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3097
+ const page = toKebabCase(options.page);
3098
+ const topic = toKebabCase(options.topic);
3099
+ if (page.length === 0) {
3100
+ return {
3101
+ stdout: "",
3102
+ stderr: "almanac: untag requires a page slug\n",
3103
+ exitCode: 1
3104
+ };
3105
+ }
3106
+ if (topic.length === 0) {
3107
+ return {
3108
+ stdout: "",
3109
+ stderr: "almanac: untag requires a topic\n",
3110
+ exitCode: 1
3111
+ };
3112
+ }
3113
+ await ensureFreshIndex({ repoRoot });
3114
+ const db = openIndex(indexDbPath(repoRoot));
3115
+ let filePath;
3116
+ try {
3117
+ const row = db.prepare(
3118
+ "SELECT file_path FROM pages WHERE slug = ?"
3119
+ ).get(page);
3120
+ if (row === void 0) {
3121
+ return {
3122
+ stdout: "",
3123
+ stderr: `almanac: no such page "${page}"
3124
+ `,
3125
+ exitCode: 1
3126
+ };
3127
+ }
3128
+ filePath = row.file_path;
3129
+ } finally {
3130
+ db.close();
3131
+ }
3132
+ const result = await rewritePageTopics(
3133
+ filePath,
3134
+ (current) => current.filter((t) => t !== topic)
3135
+ );
3136
+ if (result.changed) {
3137
+ await runIndexer({ repoRoot });
3138
+ }
3139
+ return {
3140
+ stdout: result.changed ? `untagged ${page}: ${topic}
3141
+ ` : `no change ${page} (not tagged with ${topic})
3142
+ `,
3143
+ stderr: "",
3144
+ exitCode: 0
3145
+ };
3146
+ }
3147
+
3148
+ // src/commands/topics.ts
3149
+ import { readFile as readFile11 } from "fs/promises";
3150
+ import { join as join14 } from "path";
3151
+ import fg3 from "fast-glob";
3152
+ async function runTopicsList(options) {
3153
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3154
+ await ensureFreshIndex({ repoRoot });
3155
+ const db = openIndex(indexDbPath(repoRoot));
3156
+ try {
3157
+ const rows = db.prepare(
3158
+ // page_count excludes archived pages — matches the policy used
3159
+ // by `topics show` (see `pagesDirectlyTagged`) and by every
3160
+ // page-scoped check in `health`. Pick one rule and apply it
3161
+ // everywhere; a topic with "5 pages" in `topics list` and "3
3162
+ // pages" in `topics show` is a trust-eroding inconsistency.
3163
+ `SELECT t.slug, t.title, t.description,
3164
+ (SELECT COUNT(*)
3165
+ FROM page_topics pt
3166
+ JOIN pages p ON p.slug = pt.page_slug
3167
+ WHERE pt.topic_slug = t.slug AND p.archived_at IS NULL
3168
+ ) AS page_count
3169
+ FROM topics t
3170
+ ORDER BY t.slug`
3171
+ ).all();
3172
+ if (options.json === true) {
3173
+ return {
3174
+ stdout: `${JSON.stringify(rows, null, 2)}
3175
+ `,
3176
+ stderr: "",
3177
+ exitCode: 0
3178
+ };
3179
+ }
3180
+ if (rows.length === 0) {
3181
+ return {
3182
+ stdout: "no topics. create one with `almanac topics create <name>` or tag a page.\n",
3183
+ stderr: "",
3184
+ exitCode: 0
3185
+ };
3186
+ }
3187
+ const slugWidth = rows.reduce((w, r) => Math.max(w, r.slug.length), 0);
3188
+ const lines = rows.map((r) => {
3189
+ const slug = r.slug.padEnd(slugWidth);
3190
+ const count = `(${r.page_count} page${r.page_count === 1 ? "" : "s"})`;
3191
+ return `${slug} ${count}`;
3192
+ });
3193
+ return { stdout: `${lines.join("\n")}
3194
+ `, stderr: "", exitCode: 0 };
3195
+ } finally {
3196
+ db.close();
3197
+ }
3198
+ }
3199
+ async function runTopicsShow(options) {
3200
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3201
+ await ensureFreshIndex({ repoRoot });
3202
+ const slug = toKebabCase(options.slug);
3203
+ if (slug.length === 0) {
3204
+ return {
3205
+ stdout: "",
3206
+ stderr: `almanac: empty topic slug
3207
+ `,
3208
+ exitCode: 1
3209
+ };
3210
+ }
3211
+ const db = openIndex(indexDbPath(repoRoot));
3212
+ try {
3213
+ const row = db.prepare("SELECT slug, title, description FROM topics WHERE slug = ?").get(slug);
3214
+ if (row === void 0) {
3215
+ return {
3216
+ stdout: "",
3217
+ stderr: `almanac: no such topic "${slug}"
3218
+ `,
3219
+ exitCode: 1
3220
+ };
3221
+ }
3222
+ const parents = db.prepare(
3223
+ "SELECT parent_slug FROM topic_parents WHERE child_slug = ? ORDER BY parent_slug"
3224
+ ).all(slug).map((r) => r.parent_slug);
3225
+ const children = db.prepare(
3226
+ "SELECT child_slug FROM topic_parents WHERE parent_slug = ? ORDER BY child_slug"
3227
+ ).all(slug).map((r) => r.child_slug);
3228
+ const pageSlugs = options.descendants === true ? pagesForSubtree(db, slug) : pagesDirectlyTagged(db, slug);
3229
+ const record = {
3230
+ slug: row.slug,
3231
+ title: row.title,
3232
+ description: row.description,
3233
+ parents,
3234
+ children,
3235
+ pages: pageSlugs,
3236
+ descendants_used: options.descendants === true
3237
+ };
3238
+ if (options.json === true) {
3239
+ return {
3240
+ stdout: `${JSON.stringify(record, null, 2)}
3241
+ `,
3242
+ stderr: "",
3243
+ exitCode: 0
3244
+ };
3245
+ }
3246
+ return { stdout: formatShow(record), stderr: "", exitCode: 0 };
3247
+ } finally {
3248
+ db.close();
3249
+ }
3250
+ }
3251
+ function pagesDirectlyTagged(db, slug) {
3252
+ return db.prepare(
3253
+ `SELECT pt.page_slug
3254
+ FROM page_topics pt
3255
+ JOIN pages p ON p.slug = pt.page_slug
3256
+ WHERE pt.topic_slug = ? AND p.archived_at IS NULL
3257
+ ORDER BY pt.page_slug`
3258
+ ).all(slug).map((r) => r.page_slug);
3259
+ }
3260
+ function pagesForSubtree(db, slug) {
3261
+ const slugs = [slug, ...descendantsInDb(db, slug)];
3262
+ const placeholders = slugs.map(() => "?").join(", ");
3263
+ const rows = db.prepare(
3264
+ `SELECT DISTINCT pt.page_slug
3265
+ FROM page_topics pt
3266
+ JOIN pages p ON p.slug = pt.page_slug
3267
+ WHERE pt.topic_slug IN (${placeholders}) AND p.archived_at IS NULL
3268
+ ORDER BY pt.page_slug`
3269
+ ).all(...slugs);
3270
+ return rows.map((r) => r.page_slug);
3271
+ }
3272
+ function formatShow(r) {
3273
+ const lines = [];
3274
+ lines.push(`slug: ${r.slug}`);
3275
+ lines.push(`title: ${r.title ?? titleCase(r.slug)}`);
3276
+ lines.push(`description: ${r.description ?? "\u2014"}`);
3277
+ lines.push(
3278
+ `parents: ${r.parents.length > 0 ? r.parents.join(", ") : "\u2014"}`
3279
+ );
3280
+ lines.push(
3281
+ `children: ${r.children.length > 0 ? r.children.join(", ") : "\u2014"}`
3282
+ );
3283
+ const pagesLabel = r.descendants_used === true ? "pages (incl. descendants)" : "pages";
3284
+ lines.push(`${pagesLabel}:`);
3285
+ if (r.pages.length === 0) {
3286
+ lines.push(" \u2014");
3287
+ } else {
3288
+ for (const p of r.pages) lines.push(` ${p}`);
3289
+ }
3290
+ return `${lines.join("\n")}
3291
+ `;
3292
+ }
3293
+ async function runTopicsCreate(options) {
3294
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3295
+ const slug = toKebabCase(options.name);
3296
+ if (slug.length === 0) {
3297
+ return {
3298
+ stdout: "",
3299
+ stderr: `almanac: topic name "${options.name}" has no slug-able characters
3300
+ `,
3301
+ exitCode: 1
3302
+ };
3303
+ }
3304
+ const title = options.name.trim().length > 0 ? options.name.trim() : titleCase(slug);
3305
+ await ensureFreshIndex({ repoRoot });
3306
+ const yamlPath = topicsYamlPath(repoRoot);
3307
+ const file = await loadTopicsFile(yamlPath);
3308
+ const db = openIndex(indexDbPath(repoRoot));
3309
+ try {
3310
+ const requestedParents = (options.parents ?? []).map((p) => toKebabCase(p)).filter((p) => p.length > 0);
3311
+ for (const p of requestedParents) {
3312
+ if (p === slug) {
3313
+ return {
3314
+ stdout: "",
3315
+ stderr: `almanac: topic cannot be its own parent
3316
+ `,
3317
+ exitCode: 1
3318
+ };
3319
+ }
3320
+ if (!topicExists(file, db, p)) {
3321
+ return {
3322
+ stdout: "",
3323
+ stderr: `almanac: parent topic "${p}" does not exist; create it first with \`almanac topics create ${p}\`
3324
+ `,
3325
+ exitCode: 1
3326
+ };
3327
+ }
3328
+ if (findTopic(file, p) === null) {
3329
+ ensureTopic(file, p);
3330
+ }
3331
+ }
3332
+ const existing = findTopic(file, slug);
3333
+ if (existing === null) {
3334
+ const entry = {
3335
+ slug,
3336
+ title,
3337
+ description: null,
3338
+ parents: requestedParents
3339
+ };
3340
+ file.topics.push(entry);
3341
+ } else {
3342
+ for (const p of requestedParents) {
3343
+ if (existing.parents.includes(p)) continue;
3344
+ const ancestors = ancestorsInFile(file, p);
3345
+ if (ancestors.has(slug) || p === slug) {
3346
+ return {
3347
+ stdout: "",
3348
+ stderr: `almanac: adding "${p}" as a parent of "${slug}" would create a cycle
3349
+ `,
3350
+ exitCode: 1
3351
+ };
3352
+ }
3353
+ existing.parents.push(p);
3354
+ }
3355
+ if (existing.title === titleCase(existing.slug) && title !== titleCase(slug) && title !== existing.title) {
3356
+ existing.title = title;
3357
+ }
3358
+ }
3359
+ await writeTopicsFile(yamlPath, file);
3360
+ await runIndexer({ repoRoot });
3361
+ return {
3362
+ stdout: existing === null ? `created topic "${slug}"
3363
+ ` : `updated topic "${slug}"
3364
+ `,
3365
+ stderr: "",
3366
+ exitCode: 0
3367
+ };
3368
+ } finally {
3369
+ db.close();
3370
+ }
3371
+ }
3372
+ function topicExists(file, db, slug) {
3373
+ if (findTopic(file, slug) !== null) return true;
3374
+ const row = db.prepare(
3375
+ "SELECT slug FROM topics WHERE slug = ?"
3376
+ ).get(slug);
3377
+ return row !== void 0;
3378
+ }
3379
+ async function runTopicsLink(options) {
3380
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3381
+ const child = toKebabCase(options.child);
3382
+ const parent = toKebabCase(options.parent);
3383
+ if (child.length === 0 || parent.length === 0) {
3384
+ return { stdout: "", stderr: `almanac: empty topic slug
3385
+ `, exitCode: 1 };
3386
+ }
3387
+ if (child === parent) {
3388
+ return {
3389
+ stdout: "",
3390
+ stderr: `almanac: topic cannot be its own parent
3391
+ `,
3392
+ exitCode: 1
3393
+ };
3394
+ }
3395
+ await ensureFreshIndex({ repoRoot });
3396
+ const yamlPath = topicsYamlPath(repoRoot);
3397
+ const file = await loadTopicsFile(yamlPath);
3398
+ const db = openIndex(indexDbPath(repoRoot));
3399
+ try {
3400
+ for (const slug of [child, parent]) {
3401
+ if (!topicExists(file, db, slug)) {
3402
+ return {
3403
+ stdout: "",
3404
+ stderr: `almanac: topic "${slug}" does not exist
3405
+ `,
3406
+ exitCode: 1
3407
+ };
3408
+ }
3409
+ if (findTopic(file, slug) === null) {
3410
+ ensureTopic(file, slug);
3411
+ }
3412
+ }
3413
+ const childEntry = findTopic(file, child);
3414
+ if (childEntry === null) {
3415
+ return {
3416
+ stdout: "",
3417
+ stderr: `almanac: topic "${child}" not found
3418
+ `,
3419
+ exitCode: 1
3420
+ };
3421
+ }
3422
+ if (childEntry.parents.includes(parent)) {
3423
+ return {
3424
+ stdout: `edge ${child} \u2192 ${parent} already exists
3425
+ `,
3426
+ stderr: "",
3427
+ exitCode: 0
3428
+ };
3429
+ }
3430
+ const parentAncestors = ancestorsInFile(file, parent);
3431
+ if (parentAncestors.has(child) || parent === child) {
3432
+ return {
3433
+ stdout: "",
3434
+ stderr: `almanac: adding ${parent} as parent of ${child} would create a cycle
3435
+ `,
3436
+ exitCode: 1
3437
+ };
3438
+ }
3439
+ childEntry.parents.push(parent);
3440
+ await writeTopicsFile(yamlPath, file);
3441
+ await runIndexer({ repoRoot });
3442
+ return {
3443
+ stdout: `linked ${child} \u2192 ${parent}
3444
+ `,
3445
+ stderr: "",
3446
+ exitCode: 0
3447
+ };
3448
+ } finally {
3449
+ db.close();
3450
+ }
3451
+ }
3452
+ async function runTopicsUnlink(options) {
3453
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3454
+ const child = toKebabCase(options.child);
3455
+ const parent = toKebabCase(options.parent);
3456
+ if (child.length === 0 || parent.length === 0) {
3457
+ return { stdout: "", stderr: `almanac: empty topic slug
3458
+ `, exitCode: 1 };
3459
+ }
3460
+ const yamlPath = topicsYamlPath(repoRoot);
3461
+ const file = await loadTopicsFile(yamlPath);
3462
+ const childEntry = findTopic(file, child);
3463
+ if (childEntry === null || !childEntry.parents.includes(parent)) {
3464
+ return {
3465
+ stdout: `no edge ${child} \u2192 ${parent}
3466
+ `,
3467
+ stderr: "",
3468
+ exitCode: 0
3469
+ };
3470
+ }
3471
+ childEntry.parents = childEntry.parents.filter((p) => p !== parent);
3472
+ await writeTopicsFile(yamlPath, file);
3473
+ await runIndexer({ repoRoot });
3474
+ return {
3475
+ stdout: `unlinked ${child} \u2192 ${parent}
3476
+ `,
3477
+ stderr: "",
3478
+ exitCode: 0
3479
+ };
3480
+ }
3481
+ async function runTopicsRename(options) {
3482
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3483
+ const oldSlug = toKebabCase(options.oldSlug);
3484
+ const newSlug = toKebabCase(options.newSlug);
3485
+ if (oldSlug.length === 0 || newSlug.length === 0) {
3486
+ return { stdout: "", stderr: `almanac: empty topic slug
3487
+ `, exitCode: 1 };
3488
+ }
3489
+ if (oldSlug === newSlug) {
3490
+ return {
3491
+ stdout: `topic "${oldSlug}" unchanged
3492
+ `,
3493
+ stderr: "",
3494
+ exitCode: 0
3495
+ };
3496
+ }
3497
+ await ensureFreshIndex({ repoRoot });
3498
+ const yamlPath = topicsYamlPath(repoRoot);
3499
+ const file = await loadTopicsFile(yamlPath);
3500
+ const db = openIndex(indexDbPath(repoRoot));
3501
+ let pagesUpdated;
3502
+ try {
3503
+ const oldInYaml = findTopic(file, oldSlug);
3504
+ if (!topicExists(file, db, oldSlug)) {
3505
+ return {
3506
+ stdout: "",
3507
+ stderr: `almanac: no such topic "${oldSlug}"
3508
+ `,
3509
+ exitCode: 1
3510
+ };
3511
+ }
3512
+ if (topicExists(file, db, newSlug)) {
3513
+ return {
3514
+ stdout: "",
3515
+ stderr: `almanac: topic "${newSlug}" already exists; delete it first if you intend to merge
3516
+ `,
3517
+ exitCode: 1
3518
+ };
3519
+ }
3520
+ if (oldInYaml !== null) {
3521
+ oldInYaml.slug = newSlug;
3522
+ if (oldInYaml.title === titleCase(oldSlug)) {
3523
+ oldInYaml.title = titleCase(newSlug);
3524
+ }
3525
+ }
3526
+ for (const t of file.topics) {
3527
+ t.parents = t.parents.map((p) => p === oldSlug ? newSlug : p);
3528
+ }
3529
+ await writeTopicsFile(yamlPath, file);
3530
+ pagesUpdated = await rewriteTopicOnPages(
3531
+ repoRoot,
3532
+ (topics) => topics.map((t) => t === oldSlug ? newSlug : t)
3533
+ );
3534
+ } finally {
3535
+ db.close();
3536
+ }
3537
+ await runIndexer({ repoRoot });
3538
+ return {
3539
+ stdout: `renamed ${oldSlug} \u2192 ${newSlug} (${pagesUpdated} page${pagesUpdated === 1 ? "" : "s"} updated)
3540
+ `,
3541
+ stderr: "",
3542
+ exitCode: 0
3543
+ };
3544
+ }
3545
+ async function runTopicsDelete(options) {
3546
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3547
+ const slug = toKebabCase(options.slug);
3548
+ if (slug.length === 0) {
3549
+ return { stdout: "", stderr: `almanac: empty topic slug
3550
+ `, exitCode: 1 };
3551
+ }
3552
+ await ensureFreshIndex({ repoRoot });
3553
+ const yamlPath = topicsYamlPath(repoRoot);
3554
+ const file = await loadTopicsFile(yamlPath);
3555
+ const db = openIndex(indexDbPath(repoRoot));
3556
+ let pagesUpdated;
3557
+ try {
3558
+ if (!topicExists(file, db, slug)) {
3559
+ return {
3560
+ stdout: "",
3561
+ stderr: `almanac: no such topic "${slug}"
3562
+ `,
3563
+ exitCode: 1
3564
+ };
3565
+ }
3566
+ file.topics = file.topics.filter((t) => t.slug !== slug);
3567
+ for (const t of file.topics) {
3568
+ t.parents = t.parents.filter((p) => p !== slug);
3569
+ }
3570
+ await writeTopicsFile(yamlPath, file);
3571
+ pagesUpdated = await rewriteTopicOnPages(
3572
+ repoRoot,
3573
+ (topics) => topics.filter((t) => t !== slug)
3574
+ );
3575
+ } finally {
3576
+ db.close();
3577
+ }
3578
+ await runIndexer({ repoRoot });
3579
+ return {
3580
+ stdout: `deleted topic "${slug}" (${pagesUpdated} page${pagesUpdated === 1 ? "" : "s"} untagged)
3581
+ `,
3582
+ stderr: "",
3583
+ exitCode: 0
3584
+ };
3585
+ }
3586
+ async function runTopicsDescribe(options) {
3587
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
3588
+ const slug = toKebabCase(options.slug);
3589
+ if (slug.length === 0) {
3590
+ return { stdout: "", stderr: `almanac: empty topic slug
3591
+ `, exitCode: 1 };
3592
+ }
3593
+ await ensureFreshIndex({ repoRoot });
3594
+ const yamlPath = topicsYamlPath(repoRoot);
3595
+ const file = await loadTopicsFile(yamlPath);
3596
+ const db = openIndex(indexDbPath(repoRoot));
3597
+ try {
3598
+ if (!topicExists(file, db, slug)) {
3599
+ return {
3600
+ stdout: "",
3601
+ stderr: `almanac: no such topic "${slug}"
3602
+ `,
3603
+ exitCode: 1
3604
+ };
3605
+ }
3606
+ const entry = ensureTopic(file, slug);
3607
+ const text = options.description.trim();
3608
+ entry.description = text.length === 0 ? null : text;
3609
+ await writeTopicsFile(yamlPath, file);
3610
+ } finally {
3611
+ db.close();
3612
+ }
3613
+ await runIndexer({ repoRoot });
3614
+ return {
3615
+ stdout: `described ${slug}
3616
+ `,
3617
+ stderr: "",
3618
+ exitCode: 0
3619
+ };
3620
+ }
3621
+ async function rewriteTopicOnPages(repoRoot, transform) {
3622
+ const pagesDir = join14(repoRoot, ".almanac", "pages");
3623
+ const files = await fg3("**/*.md", {
3624
+ cwd: pagesDir,
3625
+ absolute: true,
3626
+ onlyFiles: true
3627
+ });
3628
+ let changed = 0;
3629
+ for (const filePath of files) {
3630
+ const raw = await readFile11(filePath, "utf8");
3631
+ const applied = applyTopicsTransform(raw, transform);
3632
+ if (!applied.changed) continue;
3633
+ await rewritePageTopics(filePath, transform);
3634
+ changed += 1;
3635
+ }
3636
+ return changed;
3637
+ }
3638
+
3639
+ // src/registry/autoregister.ts
3640
+ import { existsSync as existsSync12 } from "fs";
3641
+ import { basename as basename5 } from "path";
3642
+ async function autoRegisterIfNeeded(cwd) {
3643
+ try {
3644
+ const repoRoot = findNearestAlmanacDir(cwd);
3645
+ if (repoRoot === null) return null;
3646
+ if (!existsSync12(repoRoot)) return null;
3647
+ const entries = await readRegistry();
3648
+ const existing = entries.find((e) => samePath(e.path, repoRoot));
3649
+ if (existing !== void 0) return existing;
3650
+ const name = toKebabCase(basename5(repoRoot));
3651
+ if (name.length === 0) return null;
3652
+ const finalName = resolveNameCollision(entries, name, repoRoot);
3653
+ if (finalName === null) return null;
3654
+ const entry = {
3655
+ name: finalName,
3656
+ description: "",
3657
+ path: repoRoot,
3658
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
3659
+ };
3660
+ await addEntry(entry);
3661
+ return entry;
3662
+ } catch (err) {
3663
+ if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES" || err.code === "EPERM")) {
3664
+ return null;
3665
+ }
3666
+ throw err;
3667
+ }
3668
+ }
3669
+ function resolveNameCollision(entries, baseName, repoPath) {
3670
+ const owner = entries.find((e) => e.name === baseName);
3671
+ if (owner === void 0 || samePath(owner.path, repoPath)) {
3672
+ return baseName;
3673
+ }
3674
+ const taken = new Set(entries.map((e) => e.name));
3675
+ const MAX_ATTEMPTS = 1e3;
3676
+ for (let suffix = 2; suffix < MAX_ATTEMPTS + 2; suffix += 1) {
3677
+ const candidate = `${baseName}-${suffix}`;
3678
+ if (!taken.has(candidate)) return candidate;
3679
+ }
3680
+ return null;
3681
+ }
3682
+ function samePath(a, b) {
3683
+ if (process.platform === "darwin" || process.platform === "win32") {
3684
+ return a.toLowerCase() === b.toLowerCase();
3685
+ }
3686
+ return a === b;
3687
+ }
3688
+
3689
+ // src/cli.ts
3690
+ async function run(argv) {
3691
+ const program = new Command();
3692
+ const invoked = argv[1] !== void 0 ? basename6(argv[1]) : "almanac";
3693
+ const programName = invoked === "codealmanac" ? "codealmanac" : "almanac";
3694
+ program.name(programName).description(
3695
+ "codealmanac \u2014 a living wiki for codebases, maintained by AI agents"
3696
+ ).version("0.1.0", "-v, --version", "print version");
3697
+ program.command("init").description("scaffold .almanac/ in the current directory and register it").option("--name <name>", "wiki name (defaults to the directory name)").option("--description <text>", "one-line description of this wiki").action(async (opts) => {
3698
+ const result = await initWiki({
3699
+ cwd: process.cwd(),
3700
+ name: opts.name,
3701
+ description: opts.description
3702
+ });
3703
+ const verb = result.created ? "initialized" : "updated";
3704
+ process.stdout.write(
3705
+ `${verb} wiki "${result.entry.name}" at ${result.almanacDir}
3706
+ `
3707
+ );
3708
+ });
3709
+ program.command("list").description("list registered wikis").option("--json", "emit structured JSON").option(
3710
+ "--drop <name>",
3711
+ "remove a wiki from the registry (the only way entries are ever removed)"
3712
+ ).action(async (opts) => {
3713
+ if (opts.drop === void 0) {
3714
+ await autoRegisterIfNeeded(process.cwd());
3715
+ }
3716
+ const result = await listWikis(opts);
3717
+ process.stdout.write(result.stdout);
3718
+ if (result.exitCode !== 0) {
3719
+ process.exitCode = result.exitCode;
3720
+ }
3721
+ });
3722
+ program.command("search [query]").description("query pages by text, topic, file mentions, or freshness").option(
3723
+ "--topic <name...>",
3724
+ "filter by topic (repeat for intersection)",
3725
+ collectOption,
3726
+ []
3727
+ ).option(
3728
+ "--mentions <path>",
3729
+ "pages referencing this file or folder (trailing / = folder)"
3730
+ ).option(
3731
+ "--since <duration>",
3732
+ "updated within duration, by file mtime (e.g. 2w, 30d)"
3733
+ ).option(
3734
+ "--stale <duration>",
3735
+ "NOT updated within duration, by file mtime"
3736
+ ).option("--orphan", "pages with no topics").option("--include-archive", "include archived pages").option("--archived", "archived pages only").option("--wiki <name>", "target a specific registered wiki").option("--json", "emit structured JSON").option("--limit <n>", "cap results", parsePositiveInt).action(
3737
+ async (query2, opts) => {
3738
+ await autoRegisterIfNeeded(process.cwd());
3739
+ const result = await runSearch({
3740
+ cwd: process.cwd(),
3741
+ query: query2,
3742
+ topics: opts.topic ?? [],
3743
+ mentions: opts.mentions,
3744
+ since: opts.since,
3745
+ stale: opts.stale,
3746
+ orphan: opts.orphan,
3747
+ includeArchive: opts.includeArchive,
3748
+ archived: opts.archived,
3749
+ wiki: opts.wiki,
3750
+ json: opts.json,
3751
+ limit: opts.limit
3752
+ });
3753
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
3754
+ process.stdout.write(result.stdout);
3755
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
3756
+ }
3757
+ );
3758
+ program.command("show [slug]").description("print the markdown content of a page").option("--stdin", "read slugs from stdin (one per line)").option("--wiki <name>", "target a specific registered wiki").action(
3759
+ async (slug, opts) => {
3760
+ await autoRegisterIfNeeded(process.cwd());
3761
+ const result = await runShow({
3762
+ cwd: process.cwd(),
3763
+ slug,
3764
+ stdin: opts.stdin,
3765
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
3766
+ wiki: opts.wiki
3767
+ });
3768
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
3769
+ process.stdout.write(result.stdout);
3770
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
3771
+ }
3772
+ );
3773
+ program.command("path [slug]").description("resolve a slug to its absolute file path").option("--stdin", "read slugs from stdin (one per line)").option("--wiki <name>", "target a specific registered wiki").action(
3774
+ async (slug, opts) => {
3775
+ await autoRegisterIfNeeded(process.cwd());
3776
+ const result = await runPath({
3777
+ cwd: process.cwd(),
3778
+ slug,
3779
+ stdin: opts.stdin,
3780
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
3781
+ wiki: opts.wiki
3782
+ });
3783
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
3784
+ process.stdout.write(result.stdout);
3785
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
3786
+ }
3787
+ );
3788
+ program.command("info [slug]").description("print metadata for a page (topics, refs, links, lineage)").option("--stdin", "read slugs from stdin (one per line)").option("--json", "emit structured JSON").option("--wiki <name>", "target a specific registered wiki").action(
3789
+ async (slug, opts) => {
3790
+ await autoRegisterIfNeeded(process.cwd());
3791
+ const result = await runInfo({
3792
+ cwd: process.cwd(),
3793
+ slug,
3794
+ stdin: opts.stdin,
3795
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
3796
+ json: opts.json,
3797
+ wiki: opts.wiki
3798
+ });
3799
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
3800
+ process.stdout.write(result.stdout);
3801
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
3802
+ }
3803
+ );
3804
+ program.command("reindex").description("force a full rebuild of .almanac/index.db").option("--wiki <name>", "target a specific registered wiki").action(async (opts) => {
3805
+ await autoRegisterIfNeeded(process.cwd());
3806
+ const result = await runReindex({
3807
+ cwd: process.cwd(),
3808
+ wiki: opts.wiki
3809
+ });
3810
+ process.stdout.write(result.stdout);
3811
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
3812
+ });
3813
+ const topics = program.command("topics").description("manage the topic DAG (list, create, link, rename, delete)");
3814
+ topics.command("list", { isDefault: true }).description("list all topics with page counts").option("--wiki <name>", "target a specific registered wiki").option("--json", "emit structured JSON").action(async (opts) => {
3815
+ await autoRegisterIfNeeded(process.cwd());
3816
+ const result = await runTopicsList({
3817
+ cwd: process.cwd(),
3818
+ wiki: opts.wiki,
3819
+ json: opts.json
3820
+ });
3821
+ emit(result);
3822
+ });
3823
+ topics.command("show <slug>").description("print a topic's metadata, parents, children, and pages").option("--descendants", "include pages tagged with descendant topics").option("--wiki <name>", "target a specific registered wiki").option("--json", "emit structured JSON").action(
3824
+ async (slug, opts) => {
3825
+ await autoRegisterIfNeeded(process.cwd());
3826
+ const result = await runTopicsShow({
3827
+ cwd: process.cwd(),
3828
+ slug,
3829
+ descendants: opts.descendants,
3830
+ wiki: opts.wiki,
3831
+ json: opts.json
3832
+ });
3833
+ emit(result);
3834
+ }
3835
+ );
3836
+ topics.command("create <name>").description("create a topic (rejects if --parent slug does not exist)").option(
3837
+ "--parent <slug>",
3838
+ "parent topic slug (repeat for multiple parents)",
3839
+ collectOption,
3840
+ []
3841
+ ).option("--wiki <name>", "target a specific registered wiki").action(
3842
+ async (name, opts) => {
3843
+ await autoRegisterIfNeeded(process.cwd());
3844
+ const result = await runTopicsCreate({
3845
+ cwd: process.cwd(),
3846
+ name,
3847
+ parents: opts.parent,
3848
+ wiki: opts.wiki
3849
+ });
3850
+ emit(result);
3851
+ }
3852
+ );
3853
+ topics.command("link <child> <parent>").description("add a DAG edge (cycle-checked)").option("--wiki <name>", "target a specific registered wiki").action(
3854
+ async (child, parent, opts) => {
3855
+ await autoRegisterIfNeeded(process.cwd());
3856
+ const result = await runTopicsLink({
3857
+ cwd: process.cwd(),
3858
+ child,
3859
+ parent,
3860
+ wiki: opts.wiki
3861
+ });
3862
+ emit(result);
3863
+ }
3864
+ );
3865
+ topics.command("unlink <child> <parent>").description("remove a DAG edge").option("--wiki <name>", "target a specific registered wiki").action(
3866
+ async (child, parent, opts) => {
3867
+ await autoRegisterIfNeeded(process.cwd());
3868
+ const result = await runTopicsUnlink({
3869
+ cwd: process.cwd(),
3870
+ child,
3871
+ parent,
3872
+ wiki: opts.wiki
3873
+ });
3874
+ emit(result);
3875
+ }
3876
+ );
3877
+ topics.command("rename <old> <new>").description("rename a topic; rewrites every affected page's frontmatter").option("--wiki <name>", "target a specific registered wiki").action(
3878
+ async (oldSlug, newSlug, opts) => {
3879
+ await autoRegisterIfNeeded(process.cwd());
3880
+ const result = await runTopicsRename({
3881
+ cwd: process.cwd(),
3882
+ oldSlug,
3883
+ newSlug,
3884
+ wiki: opts.wiki
3885
+ });
3886
+ emit(result);
3887
+ }
3888
+ );
3889
+ topics.command("delete <slug>").description("delete a topic; untags every affected page").option("--wiki <name>", "target a specific registered wiki").action(async (slug, opts) => {
3890
+ await autoRegisterIfNeeded(process.cwd());
3891
+ const result = await runTopicsDelete({
3892
+ cwd: process.cwd(),
3893
+ slug,
3894
+ wiki: opts.wiki
3895
+ });
3896
+ emit(result);
3897
+ });
3898
+ topics.command("describe <slug> <text>").description("set a topic's one-line description").option("--wiki <name>", "target a specific registered wiki").action(
3899
+ async (slug, text, opts) => {
3900
+ await autoRegisterIfNeeded(process.cwd());
3901
+ const result = await runTopicsDescribe({
3902
+ cwd: process.cwd(),
3903
+ slug,
3904
+ description: text,
3905
+ wiki: opts.wiki
3906
+ });
3907
+ emit(result);
3908
+ }
3909
+ );
3910
+ program.command("tag [page] [topics...]").description("add topics to a page (auto-creates missing topics)").option("--stdin", "read page slugs from stdin (one per line)").option("--wiki <name>", "target a specific registered wiki").action(
3911
+ async (page, topicsArg, opts) => {
3912
+ await autoRegisterIfNeeded(process.cwd());
3913
+ const resolvedTopics = opts.stdin === true ? [page, ...topicsArg].filter(
3914
+ (t) => typeof t === "string" && t.length > 0
3915
+ ) : topicsArg;
3916
+ const result = await runTag({
3917
+ cwd: process.cwd(),
3918
+ page: opts.stdin === true ? void 0 : page,
3919
+ topics: resolvedTopics,
3920
+ stdin: opts.stdin,
3921
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
3922
+ wiki: opts.wiki
3923
+ });
3924
+ emit(result);
3925
+ }
3926
+ );
3927
+ program.command("untag <page> <topic>").description("remove a topic from a page's frontmatter").option("--wiki <name>", "target a specific registered wiki").action(
3928
+ async (page, topic, opts) => {
3929
+ await autoRegisterIfNeeded(process.cwd());
3930
+ const result = await runUntag({
3931
+ cwd: process.cwd(),
3932
+ page,
3933
+ topic,
3934
+ wiki: opts.wiki
3935
+ });
3936
+ emit(result);
3937
+ }
3938
+ );
3939
+ program.command("bootstrap").description(
3940
+ "spawn an agent to scan the repo and create initial wiki stubs (requires ANTHROPIC_API_KEY)"
3941
+ ).option("--quiet", "suppress per-tool streaming; print only the final line").option("--model <model>", "override the agent model").option(
3942
+ "--force",
3943
+ "overwrite an existing populated wiki (default: refuse)"
3944
+ ).action(
3945
+ async (opts) => {
3946
+ const result = await runBootstrap({
3947
+ cwd: process.cwd(),
3948
+ quiet: opts.quiet,
3949
+ model: opts.model,
3950
+ force: opts.force
3951
+ });
3952
+ emit(result);
3953
+ }
3954
+ );
3955
+ program.command("capture [transcript]").description(
3956
+ "capture knowledge from a Claude Code session transcript (auto-resolves the most recent session for this repo when no path is given; requires ANTHROPIC_API_KEY)"
3957
+ ).option("--session <id>", "target a specific session by ID").option(
3958
+ "--quiet",
3959
+ "suppress per-tool streaming; print only the final summary"
3960
+ ).option("--model <model>", "override the agent model").action(
3961
+ async (transcript, opts) => {
3962
+ await autoRegisterIfNeeded(process.cwd());
3963
+ const result = await runCapture({
3964
+ cwd: process.cwd(),
3965
+ transcriptPath: transcript,
3966
+ sessionId: opts.session,
3967
+ quiet: opts.quiet,
3968
+ model: opts.model
3969
+ });
3970
+ emit(result);
3971
+ }
3972
+ );
3973
+ const hook = program.command("hook").description(
3974
+ "install, uninstall, or inspect the SessionEnd hook in ~/.claude/settings.json"
3975
+ );
3976
+ hook.command("install").description("add a SessionEnd entry that runs 'almanac capture' on session end").action(async () => {
3977
+ const result = await runHookInstall();
3978
+ emit(result);
3979
+ });
3980
+ hook.command("uninstall").description("remove codealmanac's SessionEnd entry; leaves foreign entries alone").action(async () => {
3981
+ const result = await runHookUninstall();
3982
+ emit(result);
3983
+ });
3984
+ hook.command("status").description("report whether the SessionEnd hook is installed").action(async () => {
3985
+ const result = await runHookStatus();
3986
+ emit(result);
3987
+ });
3988
+ program.command("health").description("report wiki problems (orphans, dead refs, broken links, \u2026)").option("--topic <name>", "scope to a topic + its descendants").option("--stale <duration>", "stale threshold (default 90d)").option("--stdin", "read page slugs from stdin (limit to these pages)").option("--json", "emit structured JSON").option("--wiki <name>", "target a specific registered wiki").action(
3989
+ async (opts) => {
3990
+ await autoRegisterIfNeeded(process.cwd());
3991
+ const result = await runHealth({
3992
+ cwd: process.cwd(),
3993
+ topic: opts.topic,
3994
+ stale: opts.stale,
3995
+ stdin: opts.stdin,
3996
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
3997
+ json: opts.json,
3998
+ wiki: opts.wiki
3999
+ });
4000
+ emit(result);
4001
+ }
4002
+ );
4003
+ await program.parseAsync(argv);
4004
+ }
4005
+ function emit(result) {
4006
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
4007
+ if (result.stdout.length > 0) process.stdout.write(result.stdout);
4008
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
4009
+ }
4010
+ function collectOption(value, previous) {
4011
+ return [...previous, value];
4012
+ }
4013
+ function parsePositiveInt(value) {
4014
+ const n = Number.parseInt(value, 10);
4015
+ if (!Number.isFinite(n) || n < 0) {
4016
+ throw new Error(`invalid --limit "${value}" (expected a non-negative integer)`);
4017
+ }
4018
+ return n;
4019
+ }
4020
+ async function readStdin() {
4021
+ if (process.stdin.isTTY === true) return "";
4022
+ const chunks = [];
4023
+ for await (const chunk of process.stdin) {
4024
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
4025
+ }
4026
+ return Buffer.concat(chunks).toString("utf8");
4027
+ }
4028
+
4029
+ // bin/codealmanac.ts
4030
+ run(process.argv).catch((err) => {
4031
+ const message = err instanceof Error ? err.message : String(err);
4032
+ process.stderr.write(`almanac: ${message}
4033
+ `);
4034
+ process.exit(1);
4035
+ });
4036
+ //# sourceMappingURL=codealmanac.js.map