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/README.ko.md +260 -120
- package/README.md +203 -114
- package/SKILL.md +38 -18
- package/dist/args.js +20 -2
- package/dist/code-index-file-policy.js +107 -2
- package/dist/code-index.js +77 -59
- package/dist/hooks.js +143 -7
- package/dist/init-project-wiki.js +237 -145
- package/dist/install-skill.js +24 -12
- package/dist/mcp-server.js +449 -0
- package/dist/migration.js +1064 -50
- package/dist/modes.js +324 -149
- package/dist/taxonomy.js +193 -0
- package/dist/templates.js +241 -42
- package/dist/wiki-files.js +24 -28
- package/dist/wiki-graph.js +141 -0
- package/package.json +11 -8
- package/README.ja.md +0 -227
- package/README.zh.md +0 -227
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
|
|
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))
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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: "
|
|
589
|
-
severity: "
|
|
560
|
+
code: "router-unreachable",
|
|
561
|
+
severity: "warn",
|
|
590
562
|
file,
|
|
591
|
-
message: `
|
|
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 (
|
|
567
|
+
else if (depth > wiki_graph_1.wikiRouterDepthBudget) {
|
|
597
568
|
diagnostics.push({
|
|
598
|
-
code: "
|
|
569
|
+
code: "router-depth-exceeded",
|
|
599
570
|
severity: "warn",
|
|
600
571
|
file,
|
|
601
|
-
message: `
|
|
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
|
-
|
|
689
|
-
|
|
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`);
|