project-librarian 0.2.0 → 0.3.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.
package/dist/modes.js CHANGED
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.buildRefreshIndexBlock = buildRefreshIndexBlock;
37
37
  exports.runQueryMode = runQueryMode;
38
+ exports.runWikiImpactMode = runWikiImpactMode;
38
39
  exports.projectCandidatesContent = projectCandidatesContent;
39
40
  exports.appendCaptureInbox = appendCaptureInbox;
40
41
  exports.runIssueDraftMode = runIssueDraftMode;
@@ -42,8 +43,14 @@ exports.runIssueCreateMode = runIssueCreateMode;
42
43
  exports.runPruneCheckMode = runPruneCheckMode;
43
44
  exports.collectLinkDiagnostics = collectLinkDiagnostics;
44
45
  exports.collectQualityDiagnostics = collectQualityDiagnostics;
46
+ exports.collectMigrationQualityDiagnostics = collectMigrationQualityDiagnostics;
47
+ exports.collectMigrationLintDiagnostics = collectMigrationLintDiagnostics;
45
48
  exports.runLinkCheckMode = runLinkCheckMode;
46
49
  exports.runQualityCheckMode = runQualityCheckMode;
50
+ exports.runMigrationQualityCheckMode = runMigrationQualityCheckMode;
51
+ exports.runMigrationLintMode = runMigrationLintMode;
52
+ exports.runMigrationDoctorMode = runMigrationDoctorMode;
53
+ exports.collectRouterTruthDiagnostics = collectRouterTruthDiagnostics;
47
54
  exports.runDoctorMode = runDoctorMode;
48
55
  exports.runLintMode = runLintMode;
49
56
  const fs = __importStar(require("node:fs"));
@@ -53,7 +60,9 @@ const path = __importStar(require("node:path"));
53
60
  const args_1 = require("./args");
54
61
  const workspace_1 = require("./workspace");
55
62
  const templates_1 = require("./templates");
63
+ const migration_1 = require("./migration");
56
64
  const wiki_files_1 = require("./wiki-files");
65
+ const wiki_graph_1 = require("./wiki-graph");
57
66
  const scopedAutoIndexThreshold = 40;
58
67
  const scopedAutoIndexMarker = "<!-- PROJECT-WIKI-SCOPED-AUTO-INDEX -->";
59
68
  function isScopedAutoIndex(file) {
@@ -161,26 +170,46 @@ This block is managed by \`--refresh-index\`. Move useful rows into a hand-writt
161
170
  | --- | --- | --- | --- |
162
171
  ${rows}<!-- PROJECT-WIKI-AUTO-INDEX:END -->`;
163
172
  }
173
+ // Answer-shaped query output (2026-06-12 method-transfer decision): first line is
174
+ // the answer, each result carries the page's TL;DR first bullet so the agent can
175
+ // pick a page without opening it, and the whole body sits under the shared hard
176
+ // cap with an explicit truncation notice.
164
177
  function runQueryMode() {
165
178
  if (!args_1.queryTerm.trim()) {
166
179
  console.error("missing query: use --query \"search terms\"");
167
180
  process.exit(1);
168
181
  }
169
182
  const terms = args_1.queryTerm.toLowerCase().split(/\s+/).filter(Boolean);
170
- const results = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => {
183
+ const matches = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => {
171
184
  const text = (0, workspace_1.read)(file);
172
185
  const body = (0, workspace_1.stripMetadataHeader)(text);
173
186
  const title = (0, wiki_files_1.wikiTitleForFile)(file, text);
174
187
  const meta = (0, wiki_files_1.metadataSummary)(file, text);
175
188
  const weighted = `${file}\n${title}\n${meta.scope}\n${(0, workspace_1.metadataValue)(text, "tags")}\n${body}`.toLowerCase();
176
189
  const score = terms.reduce((sum, term) => sum + (weighted.split(term).length - 1) + (file.toLowerCase().includes(term) ? 3 : 0) + (title.toLowerCase().includes(term) ? 5 : 0), 0);
177
- return { file, title, score, ...meta };
178
- }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)).slice(0, 10);
179
- console.log(`Project wiki query: ${args_1.queryTerm}`);
180
- if (results.length === 0)
181
- console.log("no matches");
182
- for (const item of results)
183
- console.log(`${item.score.toString().padStart(3)} ${item.file} ${item.scope} ${item.status} ${item.title}`);
190
+ return { file, title, score, tldr: (0, wiki_files_1.firstTldrBullet)(text), ...meta };
191
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
192
+ const results = matches.slice(0, 10);
193
+ const best = results[0];
194
+ const lines = [best
195
+ ? `Project wiki query "${args_1.queryTerm}": best match ${best.file} — ${best.title} (${matches.length} matching page${matches.length === 1 ? "" : "s"}, top ${results.length} shown).`
196
+ : `Project wiki query "${args_1.queryTerm}": no matches.`];
197
+ for (const item of results) {
198
+ lines.push(`${item.score.toString().padStart(3)} ${item.file} [${item.scope}|${item.status}|${item.budget}] ${item.title}`);
199
+ if (item.tldr)
200
+ lines.push(` tldr: ${item.tldr}`);
201
+ }
202
+ console.log((0, wiki_graph_1.finalizeWikiAnswer)(lines.join("\n")));
203
+ }
204
+ // Wiki impact mode: backlink/decision_ref/routing evidence for a page so wiki
205
+ // maintenance can find review candidates when project truth changes.
206
+ function runWikiImpactMode() {
207
+ if (!args_1.wikiImpactTarget.trim()) {
208
+ console.error("missing wiki impact target: use --wiki-impact \"page-or-term\"");
209
+ process.exit(1);
210
+ }
211
+ const pages = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => ({ file, text: (0, workspace_1.read)(file) }));
212
+ console.log((0, wiki_graph_1.wikiImpactAnswer)(pages, args_1.wikiImpactTarget.trim()));
184
213
  }
185
214
  function projectCandidatesContent() {
186
215
  return `${(0, templates_1.metadata)("inbox", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "candidates are adopted, rejected, or stale")}
@@ -262,6 +291,7 @@ function issueDraftMarkdown() {
262
291
  const generatedFiles = existingFileList([
263
292
  "AGENTS.md",
264
293
  "CLAUDE.md",
294
+ "GEMINI.md",
265
295
  "wiki/AGENTS.md",
266
296
  "wiki/startup.md",
267
297
  "wiki/index.md",
@@ -269,6 +299,11 @@ function issueDraftMarkdown() {
269
299
  ".codex/hooks/wiki-session-start.js",
270
300
  ".claude/settings.json",
271
301
  ".claude/hooks/wiki-session-start.js",
302
+ ".cursor/rules/project-librarian.mdc",
303
+ ".cursor/hooks.json",
304
+ ".cursor/hooks/wiki-session-start.js",
305
+ ".gemini/settings.json",
306
+ ".gemini/hooks/wiki-session-start.js",
272
307
  ".githooks/prepare-commit-msg",
273
308
  ".githooks/wiki-commit-trailers.js",
274
309
  ]);
@@ -444,15 +479,12 @@ function printDiagnostics(title, diagnostics, checked) {
444
479
  console.log(`passed: ${checked} wiki markdown files checked, ${warnings} warnings`);
445
480
  return true;
446
481
  }
447
- function collectWikiLinkReferences(files) {
448
- return files.flatMap((file) => (0, wiki_files_1.extractWikiLinks)(file, (0, workspace_1.read)(file)));
449
- }
450
482
  function collectLinkDiagnostics() {
451
483
  const diagnostics = [];
452
484
  const files = (0, wiki_files_1.wikiMarkdownFiles)();
453
485
  const fileSet = new Set(files);
454
- const links = collectWikiLinkReferences(files);
455
- for (const link of links) {
486
+ const graph = (0, wiki_graph_1.buildWikiGraph)(files.map((file) => ({ file, text: (0, workspace_1.read)(file) })));
487
+ for (const link of graph.links) {
456
488
  if (!fileSet.has(link.normalizedTarget)) {
457
489
  diagnostics.push({
458
490
  code: "broken-link",
@@ -462,25 +494,29 @@ function collectLinkDiagnostics() {
462
494
  });
463
495
  }
464
496
  }
465
- if ((0, workspace_1.exists)("wiki/index.md")) {
466
- const indexLinks = (0, wiki_files_1.extractWikiLinks)("wiki/index.md", (0, workspace_1.read)("wiki/index.md"));
467
- const indexTargets = new Map();
468
- for (const link of indexLinks)
469
- indexTargets.set(link.normalizedTarget, (indexTargets.get(link.normalizedTarget) ?? 0) + 1);
470
- for (const [target, count] of indexTargets) {
471
- if (count > 1) {
472
- diagnostics.push({
473
- code: "duplicate-route",
474
- severity: "warn",
475
- file: "wiki/index.md",
476
- message: `${count} index routes resolve to ${target}`,
477
- });
478
- }
497
+ const indexTargets = new Map();
498
+ for (const link of graph.outgoingLinks.get("wiki/index.md") ?? []) {
499
+ indexTargets.set(link.normalizedTarget, (indexTargets.get(link.normalizedTarget) ?? 0) + 1);
500
+ }
501
+ for (const [target, count] of indexTargets) {
502
+ if (count > 1) {
503
+ diagnostics.push({
504
+ code: "duplicate-route",
505
+ severity: "warn",
506
+ file: "wiki/index.md",
507
+ message: `${count} index routes resolve to ${target}`,
508
+ });
479
509
  }
480
510
  }
511
+ // Self-links are not connectivity: a page whose only incoming link is its own
512
+ // self-loop has no route into it and must stay an orphan-page finding, keeping
513
+ // the orphan and router-unreachable rules disjoint.
481
514
  const incoming = new Map();
482
- for (const link of links)
515
+ for (const link of graph.links) {
516
+ if (link.file === link.normalizedTarget)
517
+ continue;
483
518
  incoming.set(link.normalizedTarget, (incoming.get(link.normalizedTarget) ?? 0) + 1);
519
+ }
484
520
  const orphanExemptions = new Set(["wiki/index.md", "wiki/startup.md", "wiki/README.md"]);
485
521
  for (const file of files) {
486
522
  if (orphanExemptions.has(file))
@@ -494,117 +530,75 @@ function collectLinkDiagnostics() {
494
530
  });
495
531
  }
496
532
  }
497
- return diagnostics.sort((a, b) => a.severity.localeCompare(b.severity) || a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
498
- }
499
- function legacyWikiRoots() {
500
- if (!fs.existsSync(workspace_1.root))
501
- return [];
502
- return fs.readdirSync(workspace_1.root, { withFileTypes: true })
503
- .filter((entry) => entry.isDirectory() && /^wiki_legacy(?:_|$)/.test(entry.name))
504
- .map((entry) => entry.name)
505
- .sort();
506
- }
507
- function normalizeMigrationCopyText(text) {
508
- return (0, workspace_1.stripMetadataHeader)(text)
509
- .replace(/<!--[\s\S]*?-->/g, " ")
510
- .replace(/\r?\n/g, " ")
511
- .replace(/\s+/g, " ")
512
- .trim()
513
- .toLowerCase();
514
- }
515
- function migrationCopyTokens(text) {
516
- return normalizeMigrationCopyText(text).match(/[\p{L}\p{N}_./-]+/gu) ?? [];
517
- }
518
- function tokenOverlapScore(left, right) {
519
- if (left.length === 0 || right.length === 0)
520
- return 0;
521
- const counts = new Map();
522
- for (const token of right)
523
- counts.set(token, (counts.get(token) ?? 0) + 1);
524
- let overlap = 0;
525
- for (const token of left) {
526
- const count = counts.get(token) ?? 0;
527
- if (count <= 0)
528
- continue;
529
- overlap += 1;
530
- if (count === 1)
531
- counts.delete(token);
532
- else
533
- counts.set(token, count - 1);
534
- }
535
- return overlap / Math.max(left.length, right.length);
536
- }
537
- function shouldGuardAgainstMigrationCopy(file, text) {
538
- if (!/^wiki\/(?:canonical|decisions|sources)\//.test(file))
539
- return false;
540
- if (file.endsWith("/migration-inbox.md"))
541
- return false;
542
- const starter = templates_1.starterFiles[file];
543
- return !starter || normalizeMigrationCopyText(starter) !== normalizeMigrationCopyText(text);
544
- }
545
- function migrationCopyDiagnostics(files) {
546
- const roots = legacyWikiRoots();
547
- if (roots.length === 0)
548
- return [];
549
- const guardedFiles = files.filter((file) => shouldGuardAgainstMigrationCopy(file, (0, workspace_1.read)(file)));
550
- if (guardedFiles.length === 0)
551
- return [];
552
- const legacyEntries = roots
553
- .flatMap((legacyRoot) => (0, wiki_files_1.walkMarkdownFiles)((0, workspace_1.abs)(legacyRoot), [], (0, workspace_1.abs)(legacyRoot)))
554
- .map((legacyFile) => {
555
- const text = (0, workspace_1.read)(legacyFile.path);
556
- return {
557
- file: legacyFile.path,
558
- basePath: legacyFile.basePath,
559
- basename: path.basename(legacyFile.basePath).toLowerCase(),
560
- normalized: normalizeMigrationCopyText(text),
561
- tokens: migrationCopyTokens(text),
562
- };
563
- })
564
- .filter((entry) => entry.normalized.length >= 200);
565
- const diagnostics = [];
566
- for (const file of guardedFiles) {
567
- const text = (0, workspace_1.read)(file);
568
- const normalized = normalizeMigrationCopyText(text);
569
- if (normalized.length < 200)
570
- continue;
571
- const tokens = migrationCopyTokens(text);
572
- const basename = path.basename(file).toLowerCase();
573
- const relativeWithinWiki = file.replace(/^wiki\//, "");
574
- for (const legacy of legacyEntries) {
575
- if (normalized === legacy.normalized) {
576
- diagnostics.push({
577
- code: "migration-copy-risk",
578
- severity: "error",
579
- file,
580
- message: `body matches legacy document ${legacy.file}; rewrite project truth instead of copying legacy files`,
581
- });
582
- break;
583
- }
584
- if (tokens.length >= 80 && legacy.tokens.length >= 80) {
585
- const score = tokenOverlapScore(tokens, legacy.tokens);
586
- if (score >= 0.92) {
533
+ // Bounded router reachability, promoted from the benchmark fixture A1 hard
534
+ // assert (benchmarks/lib/llm-fixtures.js assertBoundedAnswerReachability) to the
535
+ // real wiki: fixture wikis were guaranteed navigable from startup while real
536
+ // wikis were never checked for the same property. Pages with zero incoming
537
+ // links are already the orphan-page rule's finding, so reachability reports
538
+ // only the cases orphan cannot see: linked-but-disconnected islands, an index
539
+ // the startup router never links (hop 1), and routes deeper than the budget.
540
+ // When wiki/startup.md itself is missing, lint owns that as a required-file
541
+ // error and reachability has no root to check from.
542
+ if (fileSet.has(wiki_graph_1.wikiRouterRoot)) {
543
+ const depths = (0, wiki_graph_1.wikiRouterDepths)(graph);
544
+ for (const file of files) {
545
+ if (wiki_graph_1.wikiRouterExemptPages.has(file))
546
+ continue;
547
+ const depth = depths.get(file);
548
+ const isIndex = file === "wiki/index.md";
549
+ if (depth === undefined) {
550
+ if (isIndex) {
551
+ diagnostics.push({
552
+ code: "router-unreachable",
553
+ severity: "warn",
554
+ file,
555
+ message: `${wiki_graph_1.wikiRouterRoot} does not link [[index]], so the router chain never starts (hop 1 broken)`,
556
+ });
557
+ }
558
+ else if ((incoming.get(file) ?? 0) > 0) {
587
559
  diagnostics.push({
588
- code: "migration-copy-risk",
589
- severity: "error",
560
+ code: "router-unreachable",
561
+ severity: "warn",
590
562
  file,
591
- message: `body is ${Math.round(score * 100)}% token-similar to legacy document ${legacy.file}; rewrite and cite current-project evidence`,
563
+ message: `linked only from pages that never connect to ${wiki_graph_1.wikiRouterRoot}; route it from wiki/index.md or a scoped router`,
592
564
  });
593
- break;
594
565
  }
595
566
  }
596
- if (relativeWithinWiki === legacy.basePath || basename === legacy.basename) {
567
+ else if (depth > wiki_graph_1.wikiRouterDepthBudget) {
597
568
  diagnostics.push({
598
- code: "migration-filename-reuse",
569
+ code: "router-depth-exceeded",
599
570
  severity: "warn",
600
571
  file,
601
- message: `filename also exists in legacy document ${legacy.file}; verify this is a rewrite, not a file copy`,
572
+ message: `reachable from ${wiki_graph_1.wikiRouterRoot} only at depth ${depth} (budget ${wiki_graph_1.wikiRouterDepthBudget}); add a shorter route`,
602
573
  });
603
- break;
604
574
  }
605
575
  }
606
576
  }
607
- return diagnostics;
577
+ return diagnostics.sort((a, b) => a.severity.localeCompare(b.severity) || a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
578
+ }
579
+ function legacyWikiRoots() {
580
+ if (!fs.existsSync(workspace_1.root))
581
+ return [];
582
+ return fs.readdirSync(workspace_1.root, { withFileTypes: true })
583
+ .filter((entry) => entry.isDirectory() && /^wiki_legacy(?:_|$)/.test(entry.name))
584
+ .map((entry) => entry.name)
585
+ .sort();
586
+ }
587
+ function shouldGuardAgainstLegacyReference(file) {
588
+ if (!/^wiki\/(?:canonical|decisions|sources)\//.test(file))
589
+ return false;
590
+ return !file.endsWith("/migration-inbox.md");
591
+ }
592
+ function migrationLegacyReferenceDiagnostics(files) {
593
+ return files
594
+ .filter(shouldGuardAgainstLegacyReference)
595
+ .filter((file) => /\bwiki_legacy(?:_|\b|\/)/.test((0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(file))))
596
+ .map((file) => ({
597
+ code: "migration-legacy-reference",
598
+ severity: "error",
599
+ file,
600
+ message: "new project truth must not link to or cite wiki_legacy*; migrate the meaning or keep unresolved material in migration inboxes",
601
+ }));
608
602
  }
609
603
  function collectQualityDiagnostics() {
610
604
  const diagnostics = [];
@@ -650,9 +644,41 @@ function collectQualityDiagnostics() {
650
644
  }
651
645
  }
652
646
  }
653
- diagnostics.push(...migrationCopyDiagnostics(files));
654
647
  return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
655
648
  }
649
+ function collectMigrationQualityDiagnostics() {
650
+ const files = (0, wiki_files_1.wikiMarkdownFiles)();
651
+ return migrationLegacyReferenceDiagnostics(files).sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
652
+ }
653
+ function collectMigrationLintDiagnostics() {
654
+ if (legacyWikiRoots().length === 0)
655
+ return [];
656
+ const requiredCoreFiles = [
657
+ "wiki/meta/document-taxonomy.md",
658
+ "wiki/migration/inventory.md",
659
+ "wiki/migration/unit-map.md",
660
+ "wiki/migration/split-plan.md",
661
+ "wiki/migration/coverage.md",
662
+ "wiki/migration/plan.md",
663
+ "wiki/migration/review.md",
664
+ "wiki/migration/verification.md",
665
+ "wiki/migration/bulk-review.md",
666
+ ];
667
+ const requiredInboxFiles = (0, migration_1.migrationSemanticReviewComplete)() ? [] : [...migration_1.generatedMigrationInboxFiles];
668
+ const requiredFiles = [...requiredCoreFiles, ...requiredInboxFiles];
669
+ const diagnostics = requiredFiles
670
+ .filter((file) => !(0, workspace_1.exists)(file))
671
+ .map((file) => ({
672
+ code: "migration-missing-file",
673
+ severity: "error",
674
+ file,
675
+ message: "migration review files are missing; run --migrate or keep migration diagnostics out of normal doctor",
676
+ }));
677
+ diagnostics.push(...(0, migration_1.collectMigrationCoverageDiagnostics)());
678
+ diagnostics.push(...(0, migration_1.collectMigrationUnitMapDiagnostics)());
679
+ diagnostics.push(...(0, migration_1.collectMigrationSplitPlanDiagnostics)());
680
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
681
+ }
656
682
  function runLinkCheckMode() {
657
683
  const ok = printDiagnostics("Project wiki link-check", collectLinkDiagnostics(), (0, wiki_files_1.wikiMarkdownFiles)().length);
658
684
  if (!ok)
@@ -663,6 +689,89 @@ function runQualityCheckMode() {
663
689
  if (!ok)
664
690
  process.exit(1);
665
691
  }
692
+ function runMigrationQualityCheckMode() {
693
+ const ok = printDiagnostics("Project wiki migration quality-check", collectMigrationQualityDiagnostics(), (0, wiki_files_1.wikiMarkdownFiles)().length);
694
+ if (!ok)
695
+ process.exit(1);
696
+ }
697
+ function runMigrationLintMode() {
698
+ const ok = printDiagnostics("Project wiki migration lint", collectMigrationLintDiagnostics(), (0, wiki_files_1.wikiMarkdownFiles)().length);
699
+ if (!ok)
700
+ process.exit(1);
701
+ }
702
+ function runMigrationDoctorMode() {
703
+ const lintOk = printDiagnostics("Project wiki migration lint", collectMigrationLintDiagnostics(), (0, wiki_files_1.wikiMarkdownFiles)().length);
704
+ const qualityOk = printDiagnostics("Project wiki migration quality-check", collectMigrationQualityDiagnostics(), (0, wiki_files_1.wikiMarkdownFiles)().length);
705
+ if (!lintOk || !qualityOk)
706
+ process.exit(1);
707
+ }
708
+ // B2 router-truth contradiction rule. A compact router that contradicts the
709
+ // decision log is worse than none: the measured 2026-06-10 run spiraled into
710
+ // post-answer verification because wiki/startup.md Recent Decisions and
711
+ // wiki/decisions/recent.md said "None yet." while wiki/decisions/log.md held the
712
+ // dated answer. This flags that exact contradiction as an error-level diagnostic.
713
+ // "None yet." is the bootstrap template marker for an empty decision surface
714
+ // (startup template "## Recent Project Decisions" and recent.md template
715
+ // "## Decisions" both seed "- None yet."), so its presence while the log carries a
716
+ // dated entry is the template-equivalent of an unmaintained router.
717
+ //
718
+ // SECTION-ANCHORED SCAN: the rule checks the relevant section body only, not the
719
+ // whole file, to avoid false-positives on other sections (e.g. an open-questions
720
+ // list that legitimately says "None yet." while Recent Decisions is maintained).
721
+ // wiki/startup.md → "## Recent Project Decisions" section body
722
+ // decisions/recent.md → "## Decisions" section body
723
+ //
724
+ // MINOR 2: the marker regex is tolerant of trailing whitespace / omitted terminal
725
+ // period ("None yet", "None yet. ") but stays anchored to the section scope above.
726
+ // Coupling: this English-only marker matches the bootstrap template text only;
727
+ // a project using a different language for these sections will not be checked.
728
+ const ROUTER_TRUTH_NONE_YET_REGEX = /\bNone yet\.?\s*$/m;
729
+ // Extract the body of a named heading section (from the heading line to the next
730
+ // same-or-higher-level heading, or end of string). Returns empty string when the
731
+ // heading is absent so the caller can decide whether to flag or skip.
732
+ function extractSectionBody(markdown, headingText) {
733
+ // Match `## <headingText>` (level-2 only, matching the template structure).
734
+ const headingRe = new RegExp(`^##\\s+${headingText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
735
+ const match = headingRe.exec(markdown);
736
+ if (!match)
737
+ return "";
738
+ const afterHeading = markdown.slice(match.index + match[0].length);
739
+ // Stop at the next ## heading (same or higher level), or end of string.
740
+ const nextHeading = /^##\s/m.exec(afterHeading);
741
+ return nextHeading ? afterHeading.slice(0, nextHeading.index) : afterHeading;
742
+ }
743
+ function collectRouterTruthDiagnostics() {
744
+ const logPath = "wiki/decisions/log.md";
745
+ if (!(0, workspace_1.exists)(logPath))
746
+ return [];
747
+ const logHasDatedEntry = /\b\d{4}-\d{2}-\d{2}\b/.test((0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(logPath)));
748
+ if (!logHasDatedEntry)
749
+ return [];
750
+ const diagnostics = [];
751
+ // Each tuple: [file, headingText, surfaceLabel]
752
+ // headingText must match the bootstrap template section heading exactly so the
753
+ // section-anchored scan never accidentally reads unrelated sections.
754
+ const routers = [
755
+ ["wiki/startup.md", "Recent Project Decisions", "Recent Decisions"],
756
+ ["wiki/decisions/recent.md", "Decisions", "Decisions"],
757
+ ];
758
+ for (const [file, heading, surface] of routers) {
759
+ if (!(0, workspace_1.exists)(file))
760
+ continue;
761
+ const section = extractSectionBody((0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(file)), heading);
762
+ if (section === "")
763
+ continue; // section absent — skip rather than false-positive
764
+ if (ROUTER_TRUTH_NONE_YET_REGEX.test(section)) {
765
+ diagnostics.push({
766
+ code: "router-truth-contradiction",
767
+ severity: "error",
768
+ file,
769
+ message: `${file} ${surface} still says "None yet." while ${logPath} holds a dated decision entry; update ${file} to reflect the recorded decision`,
770
+ });
771
+ }
772
+ }
773
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
774
+ }
666
775
  function runDoctorMode(fix) {
667
776
  if (fix) {
668
777
  console.log("Project wiki doctor --fix");
@@ -677,34 +786,45 @@ function runDoctorMode(fix) {
677
786
  const files = (0, wiki_files_1.wikiMarkdownFiles)();
678
787
  const linkOk = printDiagnostics("Project wiki link-check", collectLinkDiagnostics(), files.length);
679
788
  const qualityOk = printDiagnostics("Project wiki quality-check", collectQualityDiagnostics(), files.length);
789
+ const routerTruthOk = printDiagnostics("Project wiki router-truth check", collectRouterTruthDiagnostics(), files.length);
680
790
  runLintMode();
681
- if (!linkOk || !qualityOk)
791
+ if (!linkOk || !qualityOk || !routerTruthOk)
682
792
  process.exit(1);
683
793
  }
794
+ const commonLintRequiredFiles = [
795
+ "AGENTS.md",
796
+ "wiki/AGENTS.md",
797
+ "wiki/startup.md",
798
+ "wiki/index.md",
799
+ "wiki/decisions/log.md",
800
+ "wiki/decisions/recent.md",
801
+ "wiki/meta/operating-model.md",
802
+ "wiki/meta/decision-policy.md",
803
+ "wiki/meta/wiki-ops-v1-decisions.md",
804
+ ".githooks/prepare-commit-msg",
805
+ ".githooks/wiki-commit-trailers.js",
806
+ ];
807
+ const agentLintRequiredFiles = {
808
+ codex: [".codex/hooks/wiki-session-start.js", ".codex/hooks.json"],
809
+ claude: ["CLAUDE.md", ".claude/hooks/wiki-session-start.js", ".claude/settings.json"],
810
+ cursor: [".cursor/rules/project-librarian.mdc", ".cursor/hooks/wiki-session-start.js", ".cursor/hooks.json"],
811
+ gemini: ["GEMINI.md", ".gemini/hooks/wiki-session-start.js", ".gemini/settings.json"],
812
+ };
813
+ function activeLintAgentSurfaces() {
814
+ const active = new Set();
815
+ for (const [agent, files] of Object.entries(agentLintRequiredFiles)) {
816
+ if (files.some((file) => (0, workspace_1.exists)(file)))
817
+ active.add(agent);
818
+ }
819
+ return active;
820
+ }
684
821
  function runLintMode() {
685
822
  const errors = [];
686
823
  const warnings = [];
824
+ const activeAgents = activeLintAgentSurfaces();
687
825
  const requiredFiles = [
688
- "AGENTS.md",
689
- "CLAUDE.md",
690
- "wiki/AGENTS.md",
691
- "wiki/startup.md",
692
- "wiki/index.md",
693
- "wiki/canonical/project-brief.md",
694
- "wiki/canonical/open-questions.md",
695
- "wiki/canonical/assumptions.md",
696
- "wiki/canonical/risks.md",
697
- "wiki/decisions/log.md",
698
- "wiki/decisions/recent.md",
699
- "wiki/meta/operating-model.md",
700
- "wiki/meta/decision-policy.md",
701
- "wiki/meta/wiki-ops-v1-decisions.md",
702
- ".githooks/prepare-commit-msg",
703
- ".githooks/wiki-commit-trailers.js",
704
- ".codex/hooks/wiki-session-start.js",
705
- ".codex/hooks.json",
706
- ".claude/hooks/wiki-session-start.js",
707
- ".claude/settings.json",
826
+ ...commonLintRequiredFiles,
827
+ ...Array.from(activeAgents).flatMap((agent) => agentLintRequiredFiles[agent]),
708
828
  ];
709
829
  for (const file of requiredFiles) {
710
830
  if (!(0, workspace_1.exists)(file))
@@ -735,6 +855,13 @@ function runLintMode() {
735
855
  warnings.push("root AGENTS.md should point detailed wiki editing rules to wiki/AGENTS.md");
736
856
  if ((0, workspace_1.exists)("CLAUDE.md") && !(0, workspace_1.read)("CLAUDE.md").includes("@AGENTS.md"))
737
857
  errors.push("CLAUDE.md should import AGENTS.md for Claude Code compatibility");
858
+ if ((0, workspace_1.exists)("GEMINI.md") && !(0, workspace_1.read)("GEMINI.md").includes("@AGENTS.md"))
859
+ errors.push("GEMINI.md should import AGENTS.md for Gemini CLI compatibility");
860
+ if ((0, workspace_1.exists)(".cursor/rules/project-librarian.mdc")) {
861
+ const cursorRule = (0, workspace_1.read)(".cursor/rules/project-librarian.mdc");
862
+ if (!cursorRule.includes("alwaysApply: true") || !cursorRule.includes("@AGENTS.md"))
863
+ errors.push("Cursor project rule should always apply and reference AGENTS.md");
864
+ }
738
865
  if ((0, workspace_1.exists)("wiki/AGENTS.md") && !(0, workspace_1.read)("wiki/AGENTS.md").includes("Language policy"))
739
866
  warnings.push("wiki/AGENTS.md is missing language policy");
740
867
  for (const legacyFile of ["wiki/canonical/wiki-operating-model.md", "wiki/canonical/decision-policy.md", "wiki/decisions/wiki-v1-decisions.md"]) {
@@ -751,6 +878,16 @@ function runLintMode() {
751
878
  if (!hook.includes('["wiki/startup.md", 3500]') || !hook.includes('["wiki/index.md", 4500]'))
752
879
  errors.push("Claude startup hook does not clearly inject only startup/index with expected budgets");
753
880
  }
881
+ if ((0, workspace_1.exists)(".cursor/hooks/wiki-session-start.js")) {
882
+ const hook = (0, workspace_1.read)(".cursor/hooks/wiki-session-start.js");
883
+ if (!hook.includes('["wiki/startup.md", 3500]') || !hook.includes('["wiki/index.md", 4500]') || !hook.includes("additional_context"))
884
+ errors.push("Cursor startup hook does not clearly inject startup/index through additional_context");
885
+ }
886
+ if ((0, workspace_1.exists)(".gemini/hooks/wiki-session-start.js")) {
887
+ const hook = (0, workspace_1.read)(".gemini/hooks/wiki-session-start.js");
888
+ if (!hook.includes('["wiki/startup.md", 3500]') || !hook.includes('["wiki/index.md", 4500]') || !hook.includes("hookSpecificOutput"))
889
+ errors.push("Gemini startup hook does not clearly inject startup/index through hookSpecificOutput");
890
+ }
754
891
  if ((0, workspace_1.exists)(".claude/settings.json")) {
755
892
  const command = "node .claude/hooks/wiki-session-start.js";
756
893
  try {
@@ -772,6 +909,44 @@ function runLintMode() {
772
909
  errors.push(message);
773
910
  }
774
911
  }
912
+ if ((0, workspace_1.exists)(".cursor/hooks.json")) {
913
+ const command = "node .cursor/hooks/wiki-session-start.js";
914
+ try {
915
+ const settings = (0, workspace_1.parseJson)(".cursor/hooks.json", { version: 1, hooks: {} });
916
+ if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
917
+ throw new Error(".cursor/hooks.json has invalid hooks object");
918
+ }
919
+ const sessionStart = settings.hooks.sessionStart ?? [];
920
+ if (!Array.isArray(sessionStart) || !sessionStart.some((hook) => hook?.command === command)) {
921
+ errors.push(".cursor/hooks.json is missing the project wiki sessionStart hook");
922
+ }
923
+ }
924
+ catch (error) {
925
+ const message = error instanceof Error ? error.message : String(error);
926
+ errors.push(message);
927
+ }
928
+ }
929
+ if ((0, workspace_1.exists)(".gemini/settings.json")) {
930
+ const command = 'node "$GEMINI_PROJECT_DIR/.gemini/hooks/wiki-session-start.js"';
931
+ try {
932
+ const settings = (0, workspace_1.parseJson)(".gemini/settings.json", { hooks: {} });
933
+ if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
934
+ throw new Error(".gemini/settings.json has invalid hooks object");
935
+ }
936
+ const sessionStart = settings.hooks.SessionStart ?? [];
937
+ const configuredMatchers = new Set(sessionStart
938
+ .filter((entry) => Array.isArray(entry.hooks) && entry.hooks.some((hook) => hook.command === command))
939
+ .map((entry) => entry.matcher));
940
+ for (const matcher of ["startup", "resume", "clear"]) {
941
+ if (!configuredMatchers.has(matcher))
942
+ errors.push(`.gemini/settings.json is missing the project wiki SessionStart hook for ${matcher}`);
943
+ }
944
+ }
945
+ catch (error) {
946
+ const message = error instanceof Error ? error.message : String(error);
947
+ errors.push(message);
948
+ }
949
+ }
775
950
  for (const file of [".githooks/prepare-commit-msg", ".githooks/wiki-commit-trailers.js"]) {
776
951
  if ((0, workspace_1.exists)(file) && (fs.statSync((0, workspace_1.abs)(file)).mode & 0o111) === 0)
777
952
  errors.push(`${file} is not executable`);