project-librarian 0.2.1 → 0.4.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;
@@ -49,6 +50,7 @@ exports.runQualityCheckMode = runQualityCheckMode;
49
50
  exports.runMigrationQualityCheckMode = runMigrationQualityCheckMode;
50
51
  exports.runMigrationLintMode = runMigrationLintMode;
51
52
  exports.runMigrationDoctorMode = runMigrationDoctorMode;
53
+ exports.collectRouterTruthDiagnostics = collectRouterTruthDiagnostics;
52
54
  exports.runDoctorMode = runDoctorMode;
53
55
  exports.runLintMode = runLintMode;
54
56
  const fs = __importStar(require("node:fs"));
@@ -60,6 +62,7 @@ const workspace_1 = require("./workspace");
60
62
  const templates_1 = require("./templates");
61
63
  const migration_1 = require("./migration");
62
64
  const wiki_files_1 = require("./wiki-files");
65
+ const wiki_graph_1 = require("./wiki-graph");
63
66
  const scopedAutoIndexThreshold = 40;
64
67
  const scopedAutoIndexMarker = "<!-- PROJECT-WIKI-SCOPED-AUTO-INDEX -->";
65
68
  function isScopedAutoIndex(file) {
@@ -167,26 +170,100 @@ This block is managed by \`--refresh-index\`. Move useful rows into a hand-writt
167
170
  | --- | --- | --- | --- |
168
171
  ${rows}<!-- PROJECT-WIKI-AUTO-INDEX:END -->`;
169
172
  }
173
+ function termOccurrences(text, terms) {
174
+ const lowered = text.toLowerCase();
175
+ return terms.reduce((sum, term) => sum + (lowered.split(term).length - 1), 0);
176
+ }
177
+ function blockKindBoost(block) {
178
+ if (block.kind === "heading")
179
+ return 4;
180
+ if (block.kind === "table_row")
181
+ return 3;
182
+ if (block.kind === "list_item")
183
+ return 2;
184
+ return 1;
185
+ }
186
+ function scoreQueryBlock(block, terms) {
187
+ const occurrences = termOccurrences(`${block.headingPath.join(" ")}\n${block.text}`, terms);
188
+ return occurrences > 0 ? occurrences + blockKindBoost(block) : 0;
189
+ }
190
+ // Answer-shaped query output (2026-06-12 method-transfer decision): first line is
191
+ // the answer, each result carries the page's TL;DR first bullet and the strongest
192
+ // matching block so the agent can pick a page without opening it, and the whole
193
+ // body sits under the shared hard cap with an explicit truncation notice.
170
194
  function runQueryMode() {
171
195
  if (!args_1.queryTerm.trim()) {
172
196
  console.error("missing query: use --query \"search terms\"");
173
197
  process.exit(1);
174
198
  }
175
199
  const terms = args_1.queryTerm.toLowerCase().split(/\s+/).filter(Boolean);
176
- const results = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => {
177
- const text = (0, workspace_1.read)(file);
200
+ const pages = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => ({ file, text: (0, workspace_1.read)(file) }));
201
+ const graph = (0, wiki_graph_1.buildWikiGraph)(pages);
202
+ const routerDepths = (0, wiki_graph_1.wikiRouterDepths)(graph);
203
+ const matches = pages.map(({ file, text }) => {
178
204
  const body = (0, workspace_1.stripMetadataHeader)(text);
179
205
  const title = (0, wiki_files_1.wikiTitleForFile)(file, text);
180
206
  const meta = (0, wiki_files_1.metadataSummary)(file, text);
181
- const weighted = `${file}\n${title}\n${meta.scope}\n${(0, workspace_1.metadataValue)(text, "tags")}\n${body}`.toLowerCase();
182
- 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);
183
- return { file, title, score, ...meta };
184
- }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)).slice(0, 10);
185
- console.log(`Project wiki query: ${args_1.queryTerm}`);
186
- if (results.length === 0)
187
- console.log("no matches");
188
- for (const item of results)
189
- console.log(`${item.score.toString().padStart(3)} ${item.file} ${item.scope} ${item.status} ${item.title}`);
207
+ const metadataScore = termOccurrences(`${file}\n${title}\n${meta.scope}\n${(0, workspace_1.metadataValue)(text, "tags")}`, terms)
208
+ + terms.reduce((sum, term) => sum + (file.toLowerCase().includes(term) ? 3 : 0) + (title.toLowerCase().includes(term) ? 5 : 0), 0);
209
+ const blocks = (0, wiki_files_1.extractMarkdownBlocks)(body)
210
+ .map((block) => ({ block, score: scoreQueryBlock(block, terms) }))
211
+ .filter((item) => item.score > 0)
212
+ .sort((left, right) => right.score - left.score || left.block.line - right.block.line || left.block.id.localeCompare(right.block.id));
213
+ const topBlock = blocks[0]?.block;
214
+ const blockScore = blocks.slice(0, 5).reduce((sum, item) => sum + item.score, 0);
215
+ const score = metadataScore + blockScore;
216
+ return {
217
+ blockKind: topBlock?.kind ?? "",
218
+ blockLine: topBlock?.line ?? 0,
219
+ blockSnippet: topBlock ? (0, wiki_files_1.markdownBlockSnippet)(topBlock) : "",
220
+ file,
221
+ graphEvidence: (0, wiki_graph_1.wikiQueryGraphEvidence)(graph, file, routerDepths),
222
+ title,
223
+ score,
224
+ tldr: (0, wiki_files_1.firstTldrBullet)(text),
225
+ ...meta,
226
+ };
227
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
228
+ const resultBlocks = matches.slice(0, 10).map((item) => {
229
+ const lines = [`${item.score.toString().padStart(3)} ${item.file} [${item.scope}|${item.status}|${item.budget}] ${item.title}`];
230
+ if (item.tldr)
231
+ lines.push(` tldr: ${item.tldr}`);
232
+ if (item.blockSnippet)
233
+ lines.push(` match: ${item.blockKind}@L${item.blockLine}: ${item.blockSnippet}`);
234
+ if (item.graphEvidence)
235
+ lines.push(` graph: ${item.graphEvidence}`);
236
+ return lines;
237
+ });
238
+ const selectedBlocks = [];
239
+ const answerBudget = wiki_graph_1.wikiAnswerCharCap - wiki_graph_1.wikiAnswerTruncationNotice.length - 1;
240
+ const headlineFor = (shown) => matches[0]
241
+ ? `Project wiki query "${args_1.queryTerm}": best match ${matches[0].file} — ${matches[0].title} (${matches.length} matching page${matches.length === 1 ? "" : "s"}, top ${shown} shown).`
242
+ : `Project wiki query "${args_1.queryTerm}": no matches.`;
243
+ for (const block of resultBlocks) {
244
+ const candidateBlocks = [...selectedBlocks, block];
245
+ const candidate = [headlineFor(candidateBlocks.length), ...candidateBlocks.flat()].join("\n");
246
+ if (candidate.length > answerBudget && selectedBlocks.length > 0)
247
+ break;
248
+ selectedBlocks.push(block);
249
+ }
250
+ const best = matches[0];
251
+ const lines = [best
252
+ ? headlineFor(selectedBlocks.length)
253
+ : `Project wiki query "${args_1.queryTerm}": no matches.`];
254
+ for (const block of selectedBlocks)
255
+ lines.push(...block);
256
+ console.log((0, wiki_graph_1.finalizeWikiAnswer)(lines.join("\n")));
257
+ }
258
+ // Wiki impact mode: backlink/decision_ref/routing evidence for a page so wiki
259
+ // maintenance can find review candidates when project truth changes.
260
+ function runWikiImpactMode() {
261
+ if (!args_1.wikiImpactTarget.trim()) {
262
+ console.error("missing wiki impact target: use --wiki-impact \"page-or-term\"");
263
+ process.exit(1);
264
+ }
265
+ const pages = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => ({ file, text: (0, workspace_1.read)(file) }));
266
+ console.log((0, wiki_graph_1.wikiImpactAnswer)(pages, args_1.wikiImpactTarget.trim()));
190
267
  }
191
268
  function projectCandidatesContent() {
192
269
  return `${(0, templates_1.metadata)("inbox", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "candidates are adopted, rejected, or stale")}
@@ -456,15 +533,12 @@ function printDiagnostics(title, diagnostics, checked) {
456
533
  console.log(`passed: ${checked} wiki markdown files checked, ${warnings} warnings`);
457
534
  return true;
458
535
  }
459
- function collectWikiLinkReferences(files) {
460
- return files.flatMap((file) => (0, wiki_files_1.extractWikiLinks)(file, (0, workspace_1.read)(file)));
461
- }
462
536
  function collectLinkDiagnostics() {
463
537
  const diagnostics = [];
464
538
  const files = (0, wiki_files_1.wikiMarkdownFiles)();
465
539
  const fileSet = new Set(files);
466
- const links = collectWikiLinkReferences(files);
467
- for (const link of links) {
540
+ const graph = (0, wiki_graph_1.buildWikiGraph)(files.map((file) => ({ file, text: (0, workspace_1.read)(file) })));
541
+ for (const link of graph.links) {
468
542
  if (!fileSet.has(link.normalizedTarget)) {
469
543
  diagnostics.push({
470
544
  code: "broken-link",
@@ -474,25 +548,29 @@ function collectLinkDiagnostics() {
474
548
  });
475
549
  }
476
550
  }
477
- if ((0, workspace_1.exists)("wiki/index.md")) {
478
- const indexLinks = (0, wiki_files_1.extractWikiLinks)("wiki/index.md", (0, workspace_1.read)("wiki/index.md"));
479
- const indexTargets = new Map();
480
- for (const link of indexLinks)
481
- indexTargets.set(link.normalizedTarget, (indexTargets.get(link.normalizedTarget) ?? 0) + 1);
482
- for (const [target, count] of indexTargets) {
483
- if (count > 1) {
484
- diagnostics.push({
485
- code: "duplicate-route",
486
- severity: "warn",
487
- file: "wiki/index.md",
488
- message: `${count} index routes resolve to ${target}`,
489
- });
490
- }
551
+ const indexTargets = new Map();
552
+ for (const link of graph.outgoingLinks.get("wiki/index.md") ?? []) {
553
+ indexTargets.set(link.normalizedTarget, (indexTargets.get(link.normalizedTarget) ?? 0) + 1);
554
+ }
555
+ for (const [target, count] of indexTargets) {
556
+ if (count > 1) {
557
+ diagnostics.push({
558
+ code: "duplicate-route",
559
+ severity: "warn",
560
+ file: "wiki/index.md",
561
+ message: `${count} index routes resolve to ${target}`,
562
+ });
491
563
  }
492
564
  }
565
+ // Self-links are not connectivity: a page whose only incoming link is its own
566
+ // self-loop has no route into it and must stay an orphan-page finding, keeping
567
+ // the orphan and router-unreachable rules disjoint.
493
568
  const incoming = new Map();
494
- for (const link of links)
569
+ for (const link of graph.links) {
570
+ if (link.file === link.normalizedTarget)
571
+ continue;
495
572
  incoming.set(link.normalizedTarget, (incoming.get(link.normalizedTarget) ?? 0) + 1);
573
+ }
496
574
  const orphanExemptions = new Set(["wiki/index.md", "wiki/startup.md", "wiki/README.md"]);
497
575
  for (const file of files) {
498
576
  if (orphanExemptions.has(file))
@@ -506,6 +584,50 @@ function collectLinkDiagnostics() {
506
584
  });
507
585
  }
508
586
  }
587
+ // Bounded router reachability, promoted from the benchmark fixture A1 hard
588
+ // assert (benchmarks/lib/llm-fixtures.js assertBoundedAnswerReachability) to the
589
+ // real wiki: fixture wikis were guaranteed navigable from startup while real
590
+ // wikis were never checked for the same property. Pages with zero incoming
591
+ // links are already the orphan-page rule's finding, so reachability reports
592
+ // only the cases orphan cannot see: linked-but-disconnected islands, an index
593
+ // the startup router never links (hop 1), and routes deeper than the budget.
594
+ // When wiki/startup.md itself is missing, lint owns that as a required-file
595
+ // error and reachability has no root to check from.
596
+ if (fileSet.has(wiki_graph_1.wikiRouterRoot)) {
597
+ const depths = (0, wiki_graph_1.wikiRouterDepths)(graph);
598
+ for (const file of files) {
599
+ if (wiki_graph_1.wikiRouterExemptPages.has(file))
600
+ continue;
601
+ const depth = depths.get(file);
602
+ const isIndex = file === "wiki/index.md";
603
+ if (depth === undefined) {
604
+ if (isIndex) {
605
+ diagnostics.push({
606
+ code: "router-unreachable",
607
+ severity: "warn",
608
+ file,
609
+ message: `${wiki_graph_1.wikiRouterRoot} does not link [[index]], so the router chain never starts (hop 1 broken)`,
610
+ });
611
+ }
612
+ else if ((incoming.get(file) ?? 0) > 0) {
613
+ diagnostics.push({
614
+ code: "router-unreachable",
615
+ severity: "warn",
616
+ file,
617
+ message: `linked only from pages that never connect to ${wiki_graph_1.wikiRouterRoot}; route it from wiki/index.md or a scoped router`,
618
+ });
619
+ }
620
+ }
621
+ else if (depth > wiki_graph_1.wikiRouterDepthBudget) {
622
+ diagnostics.push({
623
+ code: "router-depth-exceeded",
624
+ severity: "warn",
625
+ file,
626
+ message: `reachable from ${wiki_graph_1.wikiRouterRoot} only at depth ${depth} (budget ${wiki_graph_1.wikiRouterDepthBudget}); add a shorter route`,
627
+ });
628
+ }
629
+ }
630
+ }
509
631
  return diagnostics.sort((a, b) => a.severity.localeCompare(b.severity) || a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
510
632
  }
511
633
  function legacyWikiRoots() {
@@ -585,15 +707,19 @@ function collectMigrationQualityDiagnostics() {
585
707
  function collectMigrationLintDiagnostics() {
586
708
  if (legacyWikiRoots().length === 0)
587
709
  return [];
588
- const requiredFiles = [
710
+ const requiredCoreFiles = [
711
+ "wiki/meta/document-taxonomy.md",
589
712
  "wiki/migration/inventory.md",
713
+ "wiki/migration/unit-map.md",
714
+ "wiki/migration/split-plan.md",
590
715
  "wiki/migration/coverage.md",
591
716
  "wiki/migration/plan.md",
717
+ "wiki/migration/review.md",
592
718
  "wiki/migration/verification.md",
593
- "wiki/canonical/migration-inbox.md",
594
- "wiki/decisions/migration-inbox.md",
595
- "wiki/sources/migration-inbox.md",
719
+ "wiki/migration/bulk-review.md",
596
720
  ];
721
+ const requiredInboxFiles = (0, migration_1.migrationSemanticReviewComplete)() ? [] : [...migration_1.generatedMigrationInboxFiles];
722
+ const requiredFiles = [...requiredCoreFiles, ...requiredInboxFiles];
597
723
  const diagnostics = requiredFiles
598
724
  .filter((file) => !(0, workspace_1.exists)(file))
599
725
  .map((file) => ({
@@ -603,6 +729,8 @@ function collectMigrationLintDiagnostics() {
603
729
  message: "migration review files are missing; run --migrate or keep migration diagnostics out of normal doctor",
604
730
  }));
605
731
  diagnostics.push(...(0, migration_1.collectMigrationCoverageDiagnostics)());
732
+ diagnostics.push(...(0, migration_1.collectMigrationUnitMapDiagnostics)());
733
+ diagnostics.push(...(0, migration_1.collectMigrationSplitPlanDiagnostics)());
606
734
  return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
607
735
  }
608
736
  function runLinkCheckMode() {
@@ -631,6 +759,73 @@ function runMigrationDoctorMode() {
631
759
  if (!lintOk || !qualityOk)
632
760
  process.exit(1);
633
761
  }
762
+ // B2 router-truth contradiction rule. A compact router that contradicts the
763
+ // decision log is worse than none: the measured 2026-06-10 run spiraled into
764
+ // post-answer verification because wiki/startup.md Recent Decisions and
765
+ // wiki/decisions/recent.md said "None yet." while wiki/decisions/log.md held the
766
+ // dated answer. This flags that exact contradiction as an error-level diagnostic.
767
+ // "None yet." is the bootstrap template marker for an empty decision surface
768
+ // (startup template "## Recent Project Decisions" and recent.md template
769
+ // "## Decisions" both seed "- None yet."), so its presence while the log carries a
770
+ // dated entry is the template-equivalent of an unmaintained router.
771
+ //
772
+ // SECTION-ANCHORED SCAN: the rule checks the relevant section body only, not the
773
+ // whole file, to avoid false-positives on other sections (e.g. an open-questions
774
+ // list that legitimately says "None yet." while Recent Decisions is maintained).
775
+ // wiki/startup.md → "## Recent Project Decisions" section body
776
+ // decisions/recent.md → "## Decisions" section body
777
+ //
778
+ // MINOR 2: the marker regex is tolerant of trailing whitespace / omitted terminal
779
+ // period ("None yet", "None yet. ") but stays anchored to the section scope above.
780
+ // Coupling: this English-only marker matches the bootstrap template text only;
781
+ // a project using a different language for these sections will not be checked.
782
+ const ROUTER_TRUTH_NONE_YET_REGEX = /\bNone yet\.?\s*$/m;
783
+ // Extract the body of a named heading section (from the heading line to the next
784
+ // same-or-higher-level heading, or end of string). Returns empty string when the
785
+ // heading is absent so the caller can decide whether to flag or skip.
786
+ function extractSectionBody(markdown, headingText) {
787
+ // Match `## <headingText>` (level-2 only, matching the template structure).
788
+ const headingRe = new RegExp(`^##\\s+${headingText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
789
+ const match = headingRe.exec(markdown);
790
+ if (!match)
791
+ return "";
792
+ const afterHeading = markdown.slice(match.index + match[0].length);
793
+ // Stop at the next ## heading (same or higher level), or end of string.
794
+ const nextHeading = /^##\s/m.exec(afterHeading);
795
+ return nextHeading ? afterHeading.slice(0, nextHeading.index) : afterHeading;
796
+ }
797
+ function collectRouterTruthDiagnostics() {
798
+ const logPath = "wiki/decisions/log.md";
799
+ if (!(0, workspace_1.exists)(logPath))
800
+ return [];
801
+ const logHasDatedEntry = /\b\d{4}-\d{2}-\d{2}\b/.test((0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(logPath)));
802
+ if (!logHasDatedEntry)
803
+ return [];
804
+ const diagnostics = [];
805
+ // Each tuple: [file, headingText, surfaceLabel]
806
+ // headingText must match the bootstrap template section heading exactly so the
807
+ // section-anchored scan never accidentally reads unrelated sections.
808
+ const routers = [
809
+ ["wiki/startup.md", "Recent Project Decisions", "Recent Decisions"],
810
+ ["wiki/decisions/recent.md", "Decisions", "Decisions"],
811
+ ];
812
+ for (const [file, heading, surface] of routers) {
813
+ if (!(0, workspace_1.exists)(file))
814
+ continue;
815
+ const section = extractSectionBody((0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(file)), heading);
816
+ if (section === "")
817
+ continue; // section absent — skip rather than false-positive
818
+ if (ROUTER_TRUTH_NONE_YET_REGEX.test(section)) {
819
+ diagnostics.push({
820
+ code: "router-truth-contradiction",
821
+ severity: "error",
822
+ file,
823
+ message: `${file} ${surface} still says "None yet." while ${logPath} holds a dated decision entry; update ${file} to reflect the recorded decision`,
824
+ });
825
+ }
826
+ }
827
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
828
+ }
634
829
  function runDoctorMode(fix) {
635
830
  if (fix) {
636
831
  console.log("Project wiki doctor --fix");
@@ -645,40 +840,45 @@ function runDoctorMode(fix) {
645
840
  const files = (0, wiki_files_1.wikiMarkdownFiles)();
646
841
  const linkOk = printDiagnostics("Project wiki link-check", collectLinkDiagnostics(), files.length);
647
842
  const qualityOk = printDiagnostics("Project wiki quality-check", collectQualityDiagnostics(), files.length);
843
+ const routerTruthOk = printDiagnostics("Project wiki router-truth check", collectRouterTruthDiagnostics(), files.length);
648
844
  runLintMode();
649
- if (!linkOk || !qualityOk)
845
+ if (!linkOk || !qualityOk || !routerTruthOk)
650
846
  process.exit(1);
651
847
  }
848
+ const commonLintRequiredFiles = [
849
+ "AGENTS.md",
850
+ "wiki/AGENTS.md",
851
+ "wiki/startup.md",
852
+ "wiki/index.md",
853
+ "wiki/decisions/log.md",
854
+ "wiki/decisions/recent.md",
855
+ "wiki/meta/operating-model.md",
856
+ "wiki/meta/decision-policy.md",
857
+ "wiki/meta/wiki-ops-v1-decisions.md",
858
+ ".githooks/prepare-commit-msg",
859
+ ".githooks/wiki-commit-trailers.js",
860
+ ];
861
+ const agentLintRequiredFiles = {
862
+ codex: [".codex/hooks/wiki-session-start.js", ".codex/hooks.json"],
863
+ claude: ["CLAUDE.md", ".claude/hooks/wiki-session-start.js", ".claude/settings.json"],
864
+ cursor: [".cursor/rules/project-librarian.mdc", ".cursor/hooks/wiki-session-start.js", ".cursor/hooks.json"],
865
+ gemini: ["GEMINI.md", ".gemini/hooks/wiki-session-start.js", ".gemini/settings.json"],
866
+ };
867
+ function activeLintAgentSurfaces() {
868
+ const active = new Set();
869
+ for (const [agent, files] of Object.entries(agentLintRequiredFiles)) {
870
+ if (files.some((file) => (0, workspace_1.exists)(file)))
871
+ active.add(agent);
872
+ }
873
+ return active;
874
+ }
652
875
  function runLintMode() {
653
876
  const errors = [];
654
877
  const warnings = [];
878
+ const activeAgents = activeLintAgentSurfaces();
655
879
  const requiredFiles = [
656
- "AGENTS.md",
657
- "CLAUDE.md",
658
- "GEMINI.md",
659
- "wiki/AGENTS.md",
660
- "wiki/startup.md",
661
- "wiki/index.md",
662
- "wiki/canonical/project-brief.md",
663
- "wiki/canonical/open-questions.md",
664
- "wiki/canonical/assumptions.md",
665
- "wiki/canonical/risks.md",
666
- "wiki/decisions/log.md",
667
- "wiki/decisions/recent.md",
668
- "wiki/meta/operating-model.md",
669
- "wiki/meta/decision-policy.md",
670
- "wiki/meta/wiki-ops-v1-decisions.md",
671
- ".githooks/prepare-commit-msg",
672
- ".githooks/wiki-commit-trailers.js",
673
- ".codex/hooks/wiki-session-start.js",
674
- ".codex/hooks.json",
675
- ".claude/hooks/wiki-session-start.js",
676
- ".claude/settings.json",
677
- ".cursor/rules/project-librarian.mdc",
678
- ".cursor/hooks/wiki-session-start.js",
679
- ".cursor/hooks.json",
680
- ".gemini/hooks/wiki-session-start.js",
681
- ".gemini/settings.json",
880
+ ...commonLintRequiredFiles,
881
+ ...Array.from(activeAgents).flatMap((agent) => agentLintRequiredFiles[agent]),
682
882
  ];
683
883
  for (const file of requiredFiles) {
684
884
  if (!(0, workspace_1.exists)(file))
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeRetrievalMetrics = computeRetrievalMetrics;
4
+ function bytesOf(text) {
5
+ return Buffer.byteLength(text, "utf8");
6
+ }
7
+ function finiteNonNegativeInteger(value, fallback) {
8
+ if (value === undefined || !Number.isFinite(value) || value < 0)
9
+ return fallback;
10
+ return Math.floor(value);
11
+ }
12
+ function itemKeys(item) {
13
+ return new Set([item.id, item.sourceId ?? "", item.blockId ?? ""].filter(Boolean));
14
+ }
15
+ function itemMatches(item, expectedId) {
16
+ return itemKeys(item).has(expectedId);
17
+ }
18
+ function ratio(numerator, denominator, emptyValue) {
19
+ if (denominator === 0)
20
+ return emptyValue;
21
+ return numerator / denominator;
22
+ }
23
+ function uniqueNonEmptyStrings(values) {
24
+ return [...new Set((values ?? []).filter((value) => value.length > 0))];
25
+ }
26
+ function answerCorrect(outputText, terms) {
27
+ const requiredTerms = uniqueNonEmptyStrings(terms);
28
+ if (requiredTerms.length === 0)
29
+ return null;
30
+ const lowered = outputText.toLowerCase();
31
+ return requiredTerms.every((term) => lowered.includes(term.toLowerCase()));
32
+ }
33
+ function computeRetrievalMetrics(results, expectation, options = {}) {
34
+ const topK = finiteNonNegativeInteger(options.topK, results.length);
35
+ const considered = results.slice(0, topK);
36
+ const requiredSourceIds = uniqueNonEmptyStrings(expectation.requiredSourceIds);
37
+ const relevantSourceIds = expectation.relevantSourceIds === undefined
38
+ ? requiredSourceIds
39
+ : uniqueNonEmptyStrings(expectation.relevantSourceIds);
40
+ const requiredHits = requiredSourceIds.filter((sourceId) => considered.some((item) => itemMatches(item, sourceId))).length;
41
+ const relevantHits = relevantSourceIds.length === 0
42
+ ? 0
43
+ : considered.filter((item) => relevantSourceIds.some((sourceId) => itemMatches(item, sourceId))).length;
44
+ const intactBlocks = considered.filter((item) => item.blockIntact !== false).length;
45
+ const maxHop = considered.reduce((currentMax, item) => Math.max(currentMax, finiteNonNegativeInteger(item.hop, 0)), 0);
46
+ const outputText = options.outputText ?? considered.map((item) => item.text ?? "").join("\n");
47
+ const computedScanBytes = considered.reduce((sum, item) => {
48
+ if (item.bytes !== undefined && Number.isFinite(item.bytes) && item.bytes >= 0)
49
+ return sum + Math.floor(item.bytes);
50
+ return sum + bytesOf(item.text ?? "");
51
+ }, 0);
52
+ const scanBytes = options.scanBytes === undefined
53
+ ? computedScanBytes
54
+ : finiteNonNegativeInteger(options.scanBytes, 0);
55
+ return {
56
+ answer_correct: answerCorrect(outputText, expectation.requiredAnswerTerms),
57
+ block_integrity: ratio(intactBlocks, considered.length, 1),
58
+ considered_results: considered.length,
59
+ evidence_precision: ratio(relevantHits, considered.length, 0),
60
+ max_hop_count: maxHop,
61
+ output_bytes: bytesOf(outputText),
62
+ required_source_count: requiredSourceIds.length,
63
+ required_source_hits: requiredHits,
64
+ scan_bytes: scanBytes,
65
+ source_hit_rate: ratio(requiredHits, requiredSourceIds.length, 1),
66
+ top_k: topK,
67
+ };
68
+ }