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/README.ko.md +261 -93
- package/README.md +190 -76
- package/SKILL.md +16 -10
- package/dist/args.js +25 -2
- package/dist/code-index-file-policy.js +105 -2
- package/dist/code-index.js +338 -70
- package/dist/hooks.js +78 -0
- package/dist/init-project-wiki.js +265 -167
- package/dist/install-skill.js +2 -9
- package/dist/mcp-server.js +709 -0
- package/dist/migration.js +875 -60
- package/dist/modes.js +262 -62
- package/dist/retrieval-eval.js +68 -0
- package/dist/taxonomy.js +193 -0
- package/dist/templates.js +214 -36
- package/dist/wiki-concepts.js +49 -0
- package/dist/wiki-files.js +120 -27
- package/dist/wiki-graph.js +166 -0
- package/dist/wiki-visualizer.js +558 -0
- package/package.json +5 -4
- package/README.ja.md +0 -215
- package/README.zh.md +0 -215
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
|
|
177
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
657
|
-
|
|
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
|
+
}
|