project-librarian 0.5.9 → 0.6.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 CHANGED
@@ -77,9 +77,9 @@ Project Librarian은 작은 첫 읽기와 더 깊은 정본으로 가는 경로
77
77
  - **첫 읽기를 작게.** 시작 훅은 `wiki/startup.md`와 `wiki/index.md`만 주입하고, 에이전트는 필요할 때 더 깊은 페이지로 이동합니다.
78
78
  - **한 번 설정, 네 에이전트.** Codex, Claude Code, Cursor, Gemini CLI가 같은 저장소 로컬 기억 계약을 공유합니다.
79
79
  - **구조적인 위키 작성.** 새 프로젝트 내용은 작성하거나 취합하기 전에 분류하므로 PRD, 정책, UX, 데이터, API, QA, 릴리즈, 운영 메모가 하나의 잡다한 페이지로 합쳐지지 않습니다.
80
- - **검토 가능한 위키 그래프.** `--wiki-visualize`는 시작 컨텍스트를 늘리지 않고 `.project-wiki/` 아래에 정적 HTML 그래프를 작성합니다.
81
80
  - **측정된 주장.** 벤치마크에서 이긴 경우와 손해를 본 경우를 주장 경계와 함께 공개합니다.
82
81
  - **로컬 세션 핸드오프.** `--handoff-save`는 `.project-wiki/session/` 아래에 생성형 재개 메모를 저장하되 실행 기억을 프로젝트 정본으로 바꾸지 않습니다.
82
+ - **답변형 위키 토폴로지.** `--wiki-impact`, `--wiki-neighborhood`, topology diagnostics가 graph visualizer 없이 backlinks, decision 근거, router depth, 가까운 읽기 순서를 노출합니다.
83
83
  - **선택적 코드 근거.** SQLite 인덱스와 답변 형태 MCP 도구가 큰 저장소의 비싼 탐색 질문에 답하며, MCP SDK 의존성은 추가하지 않습니다.
84
84
  - **다시 실행해도 안전.** 부트스트랩은 멱등하고 기존 내용 보존을 우선합니다.
85
85
 
@@ -94,6 +94,7 @@ Project Librarian은 작은 첫 읽기와 더 깊은 정본으로 가는 경로
94
94
  | 기존 문서/위키 마이그레이션 | "Project Librarian으로 기존 docs/wiki 내용을 마이그레이션해줘." |
95
95
  | 진단 실행 | "Project Librarian 진단을 실행해줘." |
96
96
  | 프로젝트 기억 검색 | "Project Librarian 위키에서 authentication decisions를 찾아줘." |
97
+ | 가까운 위키 맥락 찾기 | "canonical/project-brief의 Project Librarian wiki neighborhood를 보여줘." |
97
98
  | 코드 근거 구축 | "Project Librarian 코드 근거를 `src`에 대해 구축해줘." |
98
99
  | 코드 영향 확인 | "`healthHandler`에 대한 Project Librarian 영향 근거를 보여줘." |
99
100
  | 세션 핸드오프 저장 | "현재 작업에 대한 Project Librarian 세션 핸드오프를 저장해줘." |
package/README.md CHANGED
@@ -77,9 +77,9 @@ The core idea is not "write more docs." It is "keep the first agent read small,
77
77
  - **Small first read.** Startup hooks inject only `wiki/startup.md` and `wiki/index.md`; agents route to deeper pages on demand.
78
78
  - **One setup, four agents.** Codex, Claude Code, Cursor, and Gemini CLI share the same repo-local memory contract.
79
79
  - **Structured wiki writing.** New project content is classified before it is written or consolidated, so PRDs, policies, UX, data, APIs, QA, release, and operations notes do not collapse into one catch-all page.
80
- - **Inspectable wiki graph.** `--wiki-visualize` writes a static HTML graph under `.project-wiki/` without adding to startup context.
81
80
  - **Measured claims.** Benchmark wins and losses are published together, with claim boundaries attached.
82
81
  - **Local session handoff.** `--handoff-save` stores generated resume notes under `.project-wiki/session/` without turning execution memory into canonical project truth.
82
+ - **Answer-shaped wiki topology.** `--wiki-impact`, `--wiki-neighborhood`, and topology diagnostics expose backlinks, decision evidence, router depth, and nearby read order without a graph visualizer.
83
83
  - **Optional code evidence.** A SQLite index plus answer-shaped MCP tools answer expensive traversal questions on large repositories without adding an MCP SDK dependency.
84
84
  - **Safe to re-run.** Bootstrap is idempotent and preservation-first.
85
85
 
@@ -94,6 +94,7 @@ Ask your agent for the outcome you want; the installed skill maps the request to
94
94
  | Migrate existing docs/wiki content | "Use Project Librarian to migrate the existing docs/wiki content." |
95
95
  | Run diagnostics | "Run Project Librarian diagnostics." |
96
96
  | Search project memory | "Search the Project Librarian wiki for authentication decisions." |
97
+ | Find nearby wiki context | "Show Project Librarian wiki neighborhood for canonical/project-brief." |
97
98
  | Build code evidence | "Build Project Librarian code evidence for `src`." |
98
99
  | Inspect code impact | "Show Project Librarian impact evidence for `healthHandler`." |
99
100
  | Save a handoff | "Save a Project Librarian session handoff for the current work." |
package/dist/args.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.codeIndexMode = exports.acknowledgeSmallRepoMode = exports.noGitConfigMode = exports.reviewMigrationMode = exports.pruneCheckStrictMode = exports.pruneCheckMode = exports.captureInboxMode = exports.refreshIndexMode = exports.issueDraftMode = exports.issueCreateMode = exports.handoffVerification = exports.handoffStatusMode = exports.handoffState = exports.handoffShowMode = exports.handoffSaveMode = exports.handoffPromoteInboxMode = exports.handoffInjectionStatusMode = exports.handoffInjectionEnableMode = exports.handoffInjectionDisableMode = exports.handoffOpenQuestions = exports.handoffNextActions = exports.handoffLastSuccessCommand = exports.handoffLastFailureCommand = exports.handoffInputMode = exports.handoffGoal = exports.handoffDecisions = exports.handoffClearMode = exports.handoffBlocked = exports.glossaryMode = exports.fixMode = exports.doctorMode = exports.qualityCheckMode = exports.linkCheckMode = exports.migrationQualityCheckMode = exports.migrationLintMode = exports.migrationDoctorMode = exports.lintMode = exports.migrateMode = exports.invalidAgentTargets = exports.missingValueOptions = exports.unexpectedValueOptions = exports.unknownOptions = exports.args = exports.commandArgs = exports.command = exports.unknownCommand = exports.helpMode = exports.agentTargets = exports.parsedArgs = exports.rawArgs = void 0;
4
- exports.issueDraftTitle = exports.issueBodyFile = exports.captureCategory = exports.captureContent = exports.captureTitle = exports.codeIndexScopes = exports.codeParser = exports.codeIndexOutput = exports.codeSearchSymbol = exports.codeReportSection = exports.codeQuerySql = exports.codeImpactTarget = exports.codeContextPackTarget = exports.wikiVisualizeOutput = exports.wikiVisualizeMode = exports.wikiImpactTarget = exports.wikiImpactMode = exports.queryTerm = exports.codeSearchSymbolMode = exports.codeQueryMode = exports.codeImpactMode = exports.codeParserMode = exports.codeContextPackMode = exports.codeFilesMode = exports.codeStatusMode = exports.codeReportMode = exports.codeIndexHealthMode = exports.codeIndexFullMode = exports.codeIndexMigrateMode = exports.codeIndexIncrementalMode = exports.codeIndexEngineMode = exports.codeIndexEngine = void 0;
4
+ exports.issueDraftTitle = exports.issueBodyFile = exports.captureCategory = exports.captureContent = exports.captureTitle = exports.codeIndexScopes = exports.codeParser = exports.codeIndexOutput = exports.codeSearchSymbol = exports.codeReportSection = exports.codeQuerySql = exports.codeImpactTarget = exports.codeContextPackTarget = exports.wikiNeighborhoodTarget = exports.wikiNeighborhoodMode = exports.wikiImpactTarget = exports.wikiImpactMode = exports.queryTerm = exports.codeSearchSymbolMode = exports.codeQueryMode = exports.codeImpactMode = exports.codeParserMode = exports.codeContextPackMode = exports.codeFilesMode = exports.codeStatusMode = exports.codeReportMode = exports.codeIndexHealthMode = exports.codeIndexFullMode = exports.codeIndexMigrateMode = exports.codeIndexIncrementalMode = exports.codeIndexEngineMode = exports.codeIndexEngine = void 0;
5
5
  exports.parseArgs = parseArgs;
6
6
  exports.argValue = argValue;
7
7
  exports.argValues = argValues;
@@ -72,10 +72,8 @@ const flagDefinitions = [
72
72
  { name: "--review-migration", value: "none", aliases: ["--semantic-migrate"] },
73
73
  { name: "--scope", value: "value" },
74
74
  { name: "--title", value: "value" },
75
- { name: "--wiki-graph-html", value: "none" },
76
75
  { name: "--wiki-impact", value: "value" },
77
- { name: "--wiki-visualize", value: "none" },
78
- { name: "--wiki-visualize-out", value: "value" },
76
+ { name: "--wiki-neighborhood", value: "value" },
79
77
  ];
80
78
  function definitionNames(definition) {
81
79
  return [definition.name, ...(definition.aliases ?? [])];
@@ -156,6 +154,7 @@ function parseArgs(argv) {
156
154
  const codeContextPackTarget = argValueFromAny("--code-context-pack");
157
155
  const codeQuerySql = argValueFromAny("--code-query");
158
156
  const codeSearchSymbol = argValueFromAny("--code-search-symbol");
157
+ const wikiNeighborhoodTarget = argValue("--wiki-neighborhood");
159
158
  const parsedAgentTargets = (0, agent_surfaces_1.parseAgentSurfaceValues)(argValues("--agents"));
160
159
  const handoffInputMode = [
161
160
  "--blocked",
@@ -254,8 +253,8 @@ function parseArgs(argv) {
254
253
  .filter((arg) => !knownFlags.has(arg)))),
255
254
  wikiImpactMode: hasFlag("--wiki-impact"),
256
255
  wikiImpactTarget: argValue("--wiki-impact"),
257
- wikiVisualizeMode: hasFlag("--wiki-visualize") || hasFlag("--wiki-graph-html"),
258
- wikiVisualizeOutput: argValue("--wiki-visualize-out"),
256
+ wikiNeighborhoodMode: hasFlag("--wiki-neighborhood"),
257
+ wikiNeighborhoodTarget,
259
258
  };
260
259
  }
261
260
  exports.parsedArgs = parseArgs(exports.rawArgs);
@@ -330,8 +329,8 @@ function argValues(name) {
330
329
  exports.queryTerm = exports.parsedArgs.queryTerm;
331
330
  exports.wikiImpactMode = exports.parsedArgs.wikiImpactMode;
332
331
  exports.wikiImpactTarget = exports.parsedArgs.wikiImpactTarget;
333
- exports.wikiVisualizeMode = exports.parsedArgs.wikiVisualizeMode;
334
- exports.wikiVisualizeOutput = exports.parsedArgs.wikiVisualizeOutput;
332
+ exports.wikiNeighborhoodMode = exports.parsedArgs.wikiNeighborhoodMode;
333
+ exports.wikiNeighborhoodTarget = exports.parsedArgs.wikiNeighborhoodTarget;
335
334
  exports.codeContextPackTarget = exports.parsedArgs.codeContextPackTarget;
336
335
  exports.codeImpactTarget = exports.parsedArgs.codeImpactTarget;
337
336
  exports.codeQuerySql = exports.parsedArgs.codeQuerySql;
@@ -9,7 +9,6 @@ const modes_1 = require("./modes");
9
9
  const migration_1 = require("./migration");
10
10
  const session_handoff_1 = require("./session-handoff");
11
11
  const templates_1 = require("./templates");
12
- const wiki_visualizer_1 = require("./wiki-visualizer");
13
12
  const workspace_1 = require("./workspace");
14
13
  function codeIndex() {
15
14
  return require("./code-index");
@@ -37,8 +36,8 @@ Options:
37
36
  --dry-run With install, preview copied skill files without writing them.
38
37
  --query <terms> Search wiki paths, metadata, titles, and bodies (answer-shaped, capped output).
39
38
  --wiki-impact <page-or-term> Show wiki backlinks, decision_ref citations, and router depth for matching pages.
40
- --wiki-visualize Write a static wiki graph visualizer to .project-wiki/wiki-graph.html.
41
- --wiki-visualize-out <path> With --wiki-visualize, write under a custom .project-wiki/ path.
39
+ --wiki-neighborhood <page-or-term>
40
+ Show a bounded read order for nearby wiki pages.
42
41
  --refresh-index Update the managed auto-discovered wiki index block.
43
42
  --capture-inbox Append a candidate note with --title, --content, and optional --category.
44
43
  --handoff-save Save local generated session handoff state under .project-wiki/session/.
@@ -147,10 +146,6 @@ if (args_1.codeReportSection && !args_1.codeReportMode) {
147
146
  console.error("--code-report-section is only supported with --code-report.");
148
147
  process.exit(1);
149
148
  }
150
- if (args_1.wikiVisualizeOutput && !args_1.wikiVisualizeMode) {
151
- console.error("--wiki-visualize-out is only supported with --wiki-visualize.");
152
- process.exit(1);
153
- }
154
149
  if (args_1.pruneCheckStrictMode && !args_1.pruneCheckMode) {
155
150
  console.error("--prune-check-strict is only supported with --prune-check.");
156
151
  process.exit(1);
@@ -266,16 +261,9 @@ function runInitCommand() {
266
261
  exitAfterStdoutDrain(0);
267
262
  return;
268
263
  }
269
- if (args_1.wikiVisualizeMode) {
270
- try {
271
- const output = (0, wiki_visualizer_1.writeWikiVisualizer)(args_1.wikiVisualizeOutput);
272
- console.log(`Project wiki visualizer written: ${output}`);
273
- exitAfterStdoutDrain(0);
274
- }
275
- catch (error) {
276
- console.error(error instanceof Error ? error.message : String(error));
277
- process.exit(1);
278
- }
264
+ if (args_1.wikiNeighborhoodMode) {
265
+ (0, modes_1.runWikiNeighborhoodMode)();
266
+ exitAfterStdoutDrain(0);
279
267
  return;
280
268
  }
281
269
  if (args_1.queryTerm) {
package/dist/modes.js CHANGED
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.buildRefreshIndexBlock = buildRefreshIndexBlock;
37
37
  exports.runQueryMode = runQueryMode;
38
38
  exports.runWikiImpactMode = runWikiImpactMode;
39
+ exports.runWikiNeighborhoodMode = runWikiNeighborhoodMode;
39
40
  exports.projectCandidatesContent = projectCandidatesContent;
40
41
  exports.appendProjectCandidate = appendProjectCandidate;
41
42
  exports.appendCaptureInbox = appendCaptureInbox;
@@ -325,6 +326,14 @@ function runWikiImpactMode() {
325
326
  const corpus = (0, wiki_corpus_1.loadWikiCorpus)();
326
327
  console.log((0, wiki_graph_1.wikiImpactAnswer)(corpus.pages, args_1.wikiImpactTarget.trim(), (0, wiki_corpus_1.wikiCorpusGraph)(corpus)));
327
328
  }
329
+ function runWikiNeighborhoodMode() {
330
+ if (!args_1.wikiNeighborhoodTarget.trim()) {
331
+ console.error("missing wiki neighborhood target: use --wiki-neighborhood \"page-or-term\"");
332
+ process.exit(1);
333
+ }
334
+ const corpus = (0, wiki_corpus_1.loadWikiCorpus)();
335
+ console.log((0, wiki_graph_1.wikiNeighborhoodAnswer)(corpus.pages, args_1.wikiNeighborhoodTarget.trim(), (0, wiki_corpus_1.wikiCorpusGraph)(corpus)));
336
+ }
328
337
  function projectCandidatesContent() {
329
338
  return `${(0, templates_1.metadata)("inbox", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "candidates are adopted, rejected, or stale")}
330
339
  # Project Candidates Inbox
@@ -699,6 +708,7 @@ function collectLinkDiagnostics(corpus = (0, wiki_corpus_1.loadWikiCorpus)()) {
699
708
  }
700
709
  }
701
710
  }
711
+ diagnostics.push(...(0, wiki_diagnostics_1.collectTopologyDiagnostics)(corpus));
702
712
  return diagnostics.sort((a, b) => a.severity.localeCompare(b.severity) || a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
703
713
  }
704
714
  function legacyWikiRoots() {
@@ -725,9 +735,10 @@ function migrationLegacyReferenceDiagnostics(files, corpus) {
725
735
  message: "new project truth must not link to or cite wiki_legacy*; migrate the meaning or keep unresolved material in migration inboxes",
726
736
  }));
727
737
  }
728
- function collectQualityDiagnostics(corpus = (0, wiki_corpus_1.loadWikiCorpus)()) {
738
+ function collectQualityDiagnostics(corpus = (0, wiki_corpus_1.loadWikiCorpus)(), options = {}) {
729
739
  const diagnostics = [];
730
740
  const files = corpus.files;
741
+ const currentDate = options.today ?? workspace_1.today;
731
742
  const titles = new Map();
732
743
  for (const file of files) {
733
744
  const text = (0, wiki_corpus_1.wikiCorpusText)(corpus, file);
@@ -742,7 +753,7 @@ function collectQualityDiagnostics(corpus = (0, wiki_corpus_1.loadWikiCorpus)())
742
753
  if (tldrExpected && !/##\s+TL;DR/.test(body)) {
743
754
  diagnostics.push({ code: "missing-tldr", severity: "warn", file, message: "add a compact TL;DR near the top" });
744
755
  }
745
- const reviewAge = updated ? (0, wiki_diagnostics_1.staleReviewAge)(updated, workspace_1.today) : null;
756
+ const reviewAge = updated ? (0, wiki_diagnostics_1.staleReviewAge)(updated, currentDate) : null;
746
757
  if (status === "active" && reviewAge !== null && /project-canonical|project-decisions|source-summary|wiki-meta/.test(scope)) {
747
758
  diagnostics.push({ code: "stale-review", severity: "warn", file, message: `updated ${reviewAge} days ago: ${updated}` });
748
759
  }
@@ -29,16 +29,16 @@
29
29
  "architecture": "arm64",
30
30
  "format": "elf",
31
31
  "path": "dist/native/linux-arm64-musl/project-librarian-indexer",
32
- "sha256": "80f7440c4d115d05805b63b8118a172ddeb5a374b7154593e5b1d0b804a938ae",
33
- "size": 3100816,
32
+ "sha256": "bcb48020ae87fe3e5ebd6a8d986a91f83e67e5d69c4df9357a7c3a6c78c232b0",
33
+ "size": 3101344,
34
34
  "triple": "linux-arm64-musl"
35
35
  },
36
36
  {
37
37
  "architecture": "x64",
38
38
  "format": "elf",
39
39
  "path": "dist/native/linux-x64/project-librarian-indexer",
40
- "sha256": "3d93408db344567d7de6f477b58fdaf6aba19c943336fb7ab9720e3e9fa68c31",
41
- "size": 3110832,
40
+ "sha256": "22d32884276c23c4597bd611facd5880936b29600c8331bb3d585d2ab370a206",
41
+ "size": 3110688,
42
42
  "triple": "linux-x64"
43
43
  },
44
44
  {
@@ -53,7 +53,7 @@
53
53
  "architecture": "arm64",
54
54
  "format": "pe",
55
55
  "path": "dist/native/win32-arm64/project-librarian-indexer.exe",
56
- "sha256": "f8ef16c98ff97c4f1dd38e2e08a034a40bb219f114f8270d165feaddd8166d8f",
56
+ "sha256": "b49ad30a72a9a93ff524cc1643b141c94de4850655ce646d80579edc5b832f71",
57
57
  "size": 2098688,
58
58
  "triple": "win32-arm64"
59
59
  },
@@ -61,7 +61,7 @@
61
61
  "architecture": "x64",
62
62
  "format": "pe",
63
63
  "path": "dist/native/win32-x64/project-librarian-indexer.exe",
64
- "sha256": "5a81b330741f11d10ab5d296e8cbc8c60f1596c30a45c5f31e41fab49e5b2603",
64
+ "sha256": "36a8416e4975100f89bc57281b311b45158469fb12ea37d33dac2d5855a5e47a",
65
65
  "size": 2375680,
66
66
  "triple": "win32-x64"
67
67
  }
@@ -1,8 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.staleReviewAgeDays = void 0;
3
+ exports.topologyFanoutThreshold = exports.topologyHubOverloadThreshold = exports.staleReviewAgeDays = void 0;
4
4
  exports.staleReviewAge = staleReviewAge;
5
+ exports.collectTopologyDiagnostics = collectTopologyDiagnostics;
6
+ const wiki_corpus_1 = require("./wiki-corpus");
7
+ const wiki_graph_1 = require("./wiki-graph");
8
+ const workspace_1 = require("./workspace");
5
9
  exports.staleReviewAgeDays = 30;
10
+ exports.topologyHubOverloadThreshold = 60;
11
+ exports.topologyFanoutThreshold = 8;
6
12
  function dateOnlyMillis(value) {
7
13
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
8
14
  return null;
@@ -17,3 +23,107 @@ function staleReviewAge(updated, currentDate) {
17
23
  const ageDays = Math.floor((currentMillis - updatedMillis) / 86_400_000);
18
24
  return ageDays > exports.staleReviewAgeDays ? ageDays : null;
19
25
  }
26
+ function uniqueExisting(values, fileSet) {
27
+ return Array.from(new Set(values.filter((value) => fileSet.has(value)))).sort();
28
+ }
29
+ function isGeneratedScopedRouter(file) {
30
+ return /^wiki\/indexes\/auto-[a-z0-9-]+(?:-\d+)?\.md$/.test(file);
31
+ }
32
+ function isCanonicalTruthPage(file, text) {
33
+ return file.startsWith("wiki/canonical/") && (0, workspace_1.metadataValue)(text, "status") === "active";
34
+ }
35
+ function hasEvidenceClaimSignal(body) {
36
+ return /\b(source-backed|source backed|research-backed|external research|paper-backed|evidence-backed)\b/i.test(body);
37
+ }
38
+ function hasDecisionRefSignal(text) {
39
+ const decisionRef = (0, workspace_1.metadataValue)(text, "decision_ref").trim().toLowerCase();
40
+ return decisionRef !== "" && decisionRef !== "none";
41
+ }
42
+ function hasFocusedAuthoritySignal(text, body) {
43
+ return hasDecisionRefSignal(text) || hasEvidenceClaimSignal(body);
44
+ }
45
+ function hasEvidenceLink(file, corpus, graph = (0, wiki_corpus_1.wikiCorpusGraph)(corpus)) {
46
+ const outgoingLinks = graph.outgoingLinks.get(file) ?? [];
47
+ const sourceLink = outgoingLinks.some((link) => link.normalizedTarget.startsWith("wiki/sources/") && corpus.fileSet.has(link.normalizedTarget));
48
+ const decisionRef = graph.outgoingDecisionRef.get(file);
49
+ return sourceLink || Boolean(decisionRef && corpus.fileSet.has(decisionRef));
50
+ }
51
+ function isBroadReviewTrigger(trigger) {
52
+ const normalized = trigger.toLowerCase().trim();
53
+ if (!normalized)
54
+ return false;
55
+ return normalized === "changes"
56
+ || normalized === "project changes"
57
+ || normalized === "routine review"
58
+ || /^any\b.*changes$/.test(normalized)
59
+ || /^general\b.*changes$/.test(normalized);
60
+ }
61
+ function isTopologyHub(file) {
62
+ return file === "wiki/index.md"
63
+ || file.startsWith("wiki/meta/")
64
+ || /^wiki\/indexes\/(?!auto-)[^/]+\.md$/.test(file);
65
+ }
66
+ function collectTopologyDiagnostics(corpus) {
67
+ const diagnostics = [];
68
+ const graph = (0, wiki_corpus_1.wikiCorpusGraph)(corpus);
69
+ for (const file of corpus.files) {
70
+ if (isGeneratedScopedRouter(file))
71
+ continue;
72
+ const outgoing = uniqueExisting((graph.outgoingLinks.get(file) ?? [])
73
+ .map((link) => link.normalizedTarget)
74
+ .filter((target) => target !== file), corpus.fileSet);
75
+ if (isTopologyHub(file) && outgoing.length > exports.topologyHubOverloadThreshold) {
76
+ diagnostics.push({
77
+ code: "hub-overload",
78
+ severity: "warn",
79
+ file,
80
+ message: `${outgoing.length} outgoing wiki links exceed the topology hub threshold ${exports.topologyHubOverloadThreshold}; split or scope the route surface`,
81
+ });
82
+ }
83
+ }
84
+ for (const file of corpus.files) {
85
+ const text = (0, wiki_corpus_1.wikiCorpusText)(corpus, file);
86
+ if (isCanonicalTruthPage(file, text)) {
87
+ const body = (0, workspace_1.stripMetadataHeader)(text);
88
+ const incoming = uniqueExisting((graph.incomingLinks.get(file) ?? [])
89
+ .map((link) => link.file)
90
+ .filter((source) => source !== file), corpus.fileSet);
91
+ if (incoming.length > 0 && incoming.every(isGeneratedScopedRouter) && hasFocusedAuthoritySignal(text, body)) {
92
+ diagnostics.push({
93
+ code: "weak-authority-route",
94
+ severity: "warn",
95
+ file,
96
+ message: "active canonical page with authority signals is routed only by generated auto-index pages; add a focused route when this truth is durable",
97
+ });
98
+ }
99
+ if (hasEvidenceClaimSignal(body) && !hasEvidenceLink(file, corpus, graph)) {
100
+ diagnostics.push({
101
+ code: "missing-evidence-link",
102
+ severity: "warn",
103
+ file,
104
+ message: "canonical page makes a source-backed claim but has no source link or decision_ref evidence link",
105
+ });
106
+ }
107
+ }
108
+ }
109
+ for (const file of corpus.files) {
110
+ if (file === wiki_graph_1.wikiRouterRoot || isGeneratedScopedRouter(file))
111
+ continue;
112
+ const text = (0, wiki_corpus_1.wikiCorpusText)(corpus, file);
113
+ if ((0, workspace_1.metadataValue)(text, "status") !== "active")
114
+ continue;
115
+ const incoming = uniqueExisting((graph.incomingLinks.get(file) ?? [])
116
+ .map((link) => link.file)
117
+ .filter((source) => source !== file), corpus.fileSet);
118
+ const reviewTrigger = (0, workspace_1.metadataValue)(text, "review_trigger");
119
+ if (incoming.length >= exports.topologyFanoutThreshold && isBroadReviewTrigger(reviewTrigger)) {
120
+ diagnostics.push({
121
+ code: "stale-fanout",
122
+ severity: "warn",
123
+ file,
124
+ message: `${incoming.length} incoming links with broad review_trigger "${reviewTrigger}"; tighten review trigger before broad edits`,
125
+ });
126
+ }
127
+ }
128
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
129
+ }
@@ -6,6 +6,7 @@ exports.buildWikiGraph = buildWikiGraph;
6
6
  exports.wikiRouterDepths = wikiRouterDepths;
7
7
  exports.wikiQueryGraphEvidence = wikiQueryGraphEvidence;
8
8
  exports.wikiImpactAnswer = wikiImpactAnswer;
9
+ exports.wikiNeighborhoodAnswer = wikiNeighborhoodAnswer;
9
10
  const wiki_files_1 = require("./wiki-files");
10
11
  const workspace_1 = require("./workspace");
11
12
  // Router reachability budget. The benchmark fixture A1 assert guarantees
@@ -163,3 +164,125 @@ function wikiImpactAnswer(pages, term, graph = buildWikiGraph(pages)) {
163
164
  }
164
165
  return finalizeWikiAnswer(lines.join("\n"));
165
166
  }
167
+ const neighborhoodReadCap = 5;
168
+ function pageClassPriority(file, scope) {
169
+ if (file.startsWith("wiki/decisions/") || /decision/.test(scope))
170
+ return 90;
171
+ if (file.startsWith("wiki/sources/") || /source/.test(scope))
172
+ return 80;
173
+ if (file.startsWith("wiki/canonical/") || /canonical/.test(scope))
174
+ return 70;
175
+ if (file.startsWith("wiki/plans/") || /plan/.test(scope))
176
+ return 50;
177
+ if (file.startsWith("wiki/roadmaps/") || /roadmap/.test(scope))
178
+ return 40;
179
+ if (file.startsWith("wiki/meta/") || /wiki-meta/.test(scope))
180
+ return 30;
181
+ return 10;
182
+ }
183
+ function neighborhoodReasonRank(reason) {
184
+ if (reason === "exact page match")
185
+ return 1000;
186
+ if (reason === "title/path match")
187
+ return 900;
188
+ if (reason === "decision_ref target")
189
+ return 800;
190
+ if (reason === "decision_ref citation")
191
+ return 760;
192
+ if (reason === "outgoing link target")
193
+ return 700;
194
+ if (reason === "incoming link source")
195
+ return 650;
196
+ return 0;
197
+ }
198
+ function isGeneratedScopedRouter(file) {
199
+ return /^wiki\/indexes\/auto-[a-z0-9-]+(?:-\d+)?\.md$/.test(file);
200
+ }
201
+ function isRouterOnlySurface(file) {
202
+ return file === exports.wikiRouterRoot || file === "wiki/index.md" || isGeneratedScopedRouter(file);
203
+ }
204
+ function addNeighborhoodCandidate(candidates, file, reason, bestFile, graph) {
205
+ if (!graph.files.has(file) || (file !== bestFile && isRouterOnlySurface(file)))
206
+ return;
207
+ const existing = candidates.get(file);
208
+ if (!existing || neighborhoodReasonRank(reason) > neighborhoodReasonRank(existing.reason)) {
209
+ candidates.set(file, { file, reason });
210
+ }
211
+ }
212
+ function wikiNeighborhoodAnswer(pages, term, graph = buildWikiGraph(pages)) {
213
+ const depths = wikiRouterDepths(graph);
214
+ const textByFile = new Map(pages.map((page) => [page.file, page.text]));
215
+ const lowered = term.toLowerCase();
216
+ const exactTarget = (0, wiki_files_1.normalizeWikiLinkTarget)("wiki/index.md", term.replace(/^\[\[|\]\]$/g, ""));
217
+ const matches = pages
218
+ .map((page) => ({ file: page.file, title: (0, wiki_files_1.wikiTitleForFile)(page.file, page.text), text: page.text }))
219
+ .filter((page) => page.file === exactTarget || page.file.toLowerCase().includes(lowered) || page.title.toLowerCase().includes(lowered))
220
+ .sort((a, b) => {
221
+ const exactDelta = Number(b.file === exactTarget) - Number(a.file === exactTarget);
222
+ if (exactDelta !== 0)
223
+ return exactDelta;
224
+ const titleDelta = Number(b.title.toLowerCase().includes(lowered)) - Number(a.title.toLowerCase().includes(lowered));
225
+ if (titleDelta !== 0)
226
+ return titleDelta;
227
+ const aScope = (0, workspace_1.metadataValue)(a.text, "scope");
228
+ const bScope = (0, workspace_1.metadataValue)(b.text, "scope");
229
+ return pageClassPriority(b.file, bScope) - pageClassPriority(a.file, aScope) || a.file.localeCompare(b.file);
230
+ });
231
+ if (matches.length === 0)
232
+ return `Wiki neighborhood "${term}": no matching wiki pages.`;
233
+ const best = matches[0];
234
+ const candidates = new Map();
235
+ addNeighborhoodCandidate(candidates, best.file, best.file === exactTarget ? "exact page match" : "title/path match", best.file, graph);
236
+ const outgoingRef = graph.outgoingDecisionRef.get(best.file);
237
+ if (outgoingRef)
238
+ addNeighborhoodCandidate(candidates, outgoingRef, "decision_ref target", best.file, graph);
239
+ for (const source of graph.incomingDecisionRefs.get(best.file) ?? [])
240
+ addNeighborhoodCandidate(candidates, source, "decision_ref citation", best.file, graph);
241
+ for (const link of graph.outgoingLinks.get(best.file) ?? [])
242
+ addNeighborhoodCandidate(candidates, link.normalizedTarget, "outgoing link target", best.file, graph);
243
+ for (const link of graph.incomingLinks.get(best.file) ?? [])
244
+ addNeighborhoodCandidate(candidates, link.file, "incoming link source", best.file, graph);
245
+ const sorted = Array.from(candidates.values()).sort((a, b) => {
246
+ const reasonDelta = neighborhoodReasonRank(b.reason) - neighborhoodReasonRank(a.reason);
247
+ if (reasonDelta !== 0)
248
+ return reasonDelta;
249
+ const aText = textByFile.get(a.file) ?? "";
250
+ const bText = textByFile.get(b.file) ?? "";
251
+ const classDelta = pageClassPriority(b.file, (0, workspace_1.metadataValue)(bText, "scope")) - pageClassPriority(a.file, (0, workspace_1.metadataValue)(aText, "scope"));
252
+ if (classDelta !== 0)
253
+ return classDelta;
254
+ const aDepth = depths.get(a.file) ?? Number.POSITIVE_INFINITY;
255
+ const bDepth = depths.get(b.file) ?? Number.POSITIVE_INFINITY;
256
+ return aDepth - bDepth || a.file.localeCompare(b.file);
257
+ }).slice(0, neighborhoodReadCap);
258
+ const lines = [
259
+ `Wiki neighborhood "${term}": best match ${best.file} — ${best.title}; read ${sorted.length} nearby page${sorted.length === 1 ? "" : "s"}.`,
260
+ "",
261
+ "Read order:",
262
+ ];
263
+ sorted.forEach((candidate, index) => {
264
+ const text = textByFile.get(candidate.file) ?? "";
265
+ const depth = depths.get(candidate.file);
266
+ const status = (0, workspace_1.metadataValue)(text, "status") || "unknown";
267
+ const scope = (0, workspace_1.metadataValue)(text, "scope") || "unknown-scope";
268
+ lines.push(`${index + 1}. ${candidate.file} — ${candidate.reason}; ${depth === undefined ? "router unreachable" : `router depth ${depth}`}; ${status} ${scope}`);
269
+ });
270
+ const incoming = uniqueSorted((graph.incomingLinks.get(best.file) ?? [])
271
+ .map((link) => link.file)
272
+ .filter((source) => source !== best.file && graph.files.has(source)));
273
+ const outgoing = uniqueSorted((graph.outgoingLinks.get(best.file) ?? [])
274
+ .map((link) => link.normalizedTarget)
275
+ .filter((target) => target !== best.file && graph.files.has(target)));
276
+ const incomingRefs = uniqueSorted((graph.incomingDecisionRefs.get(best.file) ?? [])
277
+ .filter((source) => source !== best.file && graph.files.has(source)));
278
+ const reviewTrigger = (0, workspace_1.metadataValue)(best.text, "review_trigger");
279
+ lines.push("");
280
+ lines.push("Why:");
281
+ lines.push(`- incoming links: ${sampled(incoming, impactListCap)}`);
282
+ lines.push(`- outgoing links: ${sampled(outgoing, impactListCap)}`);
283
+ lines.push(`- decision_ref: ${outgoingRef && graph.files.has(outgoingRef) ? outgoingRef : "none"}`);
284
+ lines.push(`- decision_ref citations: ${sampled(incomingRefs, impactListCap)}`);
285
+ if (reviewTrigger)
286
+ lines.push(`- review_trigger: ${reviewTrigger}`);
287
+ return finalizeWikiAnswer(lines.join("\n"));
288
+ }
@@ -19,7 +19,7 @@ node .codex/skills/project-librarian/dist/init-project-wiki.js install [--scope
19
19
  | `update --agents <list>` | Refresh an existing setup and existing project-scoped skill copies; selected surfaces can be `codex`, `claude`, `cursor`, `gemini`, or `all`. |
20
20
  | `--migrate`, `--adopt-existing` | Preserve an existing wiki as `wiki_legacy*`, create migration inboxes, and generate unit-map/split-plan/coverage review files. |
21
21
  | `--lint` | Validate generated setup without editing files. |
22
- | `--link-check` | Report broken wiki links, duplicate routes, orphan pages, and pages the startup router cannot reach within the depth budget. |
22
+ | `--link-check` | Report broken wiki links, duplicate routes, orphan pages, router reachability, and warning-only topology signals. |
23
23
  | `--quality-check` | Report stale, conflicting, and low-quality wiki document signals. |
24
24
  | `--doctor` | Run lint, link-check, and quality-check together. |
25
25
  | `--doctor --fix` | Safely refresh generated index routing before diagnostics. `--fix` is only a modifier for `--doctor`. |
@@ -28,8 +28,7 @@ node .codex/skills/project-librarian/dist/init-project-wiki.js install [--scope
28
28
  | `--migration-doctor` | Run migration-lint and migration-quality-check together. |
29
29
  | `--query <terms>` | Search wiki paths, metadata, titles, and bodies; answer-first output with per-page TL;DR lines under a hard size cap. |
30
30
  | `--wiki-impact <page-or-term>` | Show wiki backlinks, `decision_ref` citations, outgoing links, and router depth for matching pages. |
31
- | `--wiki-visualize` | Write a self-contained static wiki graph visualizer to `.project-wiki/wiki-graph.html`. |
32
- | `--wiki-visualize-out <path>` | With `--wiki-visualize`, write to a custom repository-relative path under `.project-wiki/`. |
31
+ | `--wiki-neighborhood <page-or-term>` | Show a bounded read order for nearby wiki pages using links, `decision_ref`, metadata, page class, and router depth. |
33
32
  | `--refresh-index` | Update generated auto-discovered wiki routing. |
34
33
  | `--capture-inbox --title <title> --content <content> --category <category>` | Append a candidate note to the wiki inbox; category defaults to `project-candidate`. |
35
34
  | `--handoff-save --goal <goal> --state <state> --next <action>` | Save generated local session handoff state under `.project-wiki/session/`. Repeat `--next`, `--decision`, `--blocked`, `--open-question`, `--verification`, `--last-success-command`, and `--last-failure-command` as needed. |
@@ -59,3 +58,14 @@ node .codex/skills/project-librarian/dist/init-project-wiki.js install [--scope
59
58
  | `--code-context-pack <term>` | Print a budgeted first-pass context pack with structural file, symbol, route, import, edge, and ownership evidence. |
60
59
  | `--code-search-symbol <term>` | Search indexed symbols. |
61
60
  | `--code-query <sql>` | Run conservative read-only SQL over the evidence index. |
61
+
62
+ ### Topology Warnings
63
+
64
+ `--link-check` keeps topology findings warning-only so they can guide cleanup without blocking bootstrap, update, or release flows.
65
+
66
+ | Code | Meaning |
67
+ | --- | --- |
68
+ | `hub-overload` | A hand-maintained router or meta page links to too many wiki pages and should be split or scoped. |
69
+ | `weak-authority-route` | An active canonical page with decision or evidence authority signals is reachable only through generated auto-index routing. |
70
+ | `missing-evidence-link` | An active canonical page makes a source-backed claim without a source link or `decision_ref`. |
71
+ | `stale-fanout` | A heavily linked active page has a broad review trigger that is too weak for topology-sensitive edits. |
@@ -19,7 +19,7 @@ node .codex/skills/project-librarian/dist/init-project-wiki.js install [--scope
19
19
  | `update --agents <list>` | 기존 설정과 기존 프로젝트 범위 스킬 복사본을 갱신합니다. 표면은 `codex`, `claude`, `cursor`, `gemini`, `all` 중 하나입니다. |
20
20
  | `--migrate`, `--adopt-existing` | 기존 위키를 `wiki_legacy*`로 보존하고 migration inbox, unit-map, split-plan, coverage 검토 파일을 만듭니다. |
21
21
  | `--lint` | 파일을 수정하지 않고 생성된 설정을 검증합니다. |
22
- | `--link-check` | 깨진 위키 링크, 중복 route, orphan page, 시작 라우터 depth 예산 안에서 닿지 않는 페이지를 보고합니다. |
22
+ | `--link-check` | 깨진 위키 링크, 중복 route, orphan page, router reachability, warning-only topology 신호를 보고합니다. |
23
23
  | `--quality-check` | 오래되었거나 충돌하거나 품질이 낮은 위키 문서 신호를 보고합니다. |
24
24
  | `--doctor` | lint, link-check, quality-check를 함께 실행합니다. |
25
25
  | `--doctor --fix` | 진단 전에 생성된 index routing을 안전하게 갱신합니다. `--fix`는 `--doctor`의 modifier입니다. |
@@ -28,8 +28,7 @@ node .codex/skills/project-librarian/dist/init-project-wiki.js install [--scope
28
28
  | `--migration-doctor` | migration-lint와 migration-quality-check를 함께 실행합니다. |
29
29
  | `--query <terms>` | 위키 경로, 메타데이터, 제목, 본문을 검색하고 크기 제한이 있는 answer-first 출력을 제공합니다. |
30
30
  | `--wiki-impact <page-or-term>` | 일치하는 페이지의 backlinks, `decision_ref` 인용, outgoing link, router depth를 보여줍니다. |
31
- | `--wiki-visualize` | `.project-wiki/wiki-graph.html`에 독립 실행형 정적 위키 그래프를 작성합니다. |
32
- | `--wiki-visualize-out <path>` | `--wiki-visualize`와 함께 사용해 `.project-wiki/` 아래 사용자 지정 저장소 상대 경로에 작성합니다. |
31
+ | `--wiki-neighborhood <page-or-term>` | link, `decision_ref`, metadata, page class, router depth를 사용해 가까운 위키 페이지의 제한된 읽기 순서를 보여줍니다. |
33
32
  | `--refresh-index` | 자동 발견된 위키 routing을 갱신합니다. |
34
33
  | `--capture-inbox --title <title> --content <content> --category <category>` | 후보 메모를 위키 inbox에 추가합니다. category 기본값은 `project-candidate`입니다. |
35
34
  | `--handoff-save --goal <goal> --state <state> --next <action>` | `.project-wiki/session/` 아래에 생성형 로컬 세션 핸드오프를 저장합니다. 필요하면 `--next`, `--decision`, `--blocked`, `--open-question`, `--verification`, `--last-success-command`, `--last-failure-command`를 반복합니다. |
@@ -59,3 +58,14 @@ node .codex/skills/project-librarian/dist/init-project-wiki.js install [--scope
59
58
  | `--code-context-pack <term>` | 구조 파일, 심볼, route, import, edge, 소유권 근거를 담은 예산 제한 first-pass context pack을 출력합니다. |
60
59
  | `--code-search-symbol <term>` | 인덱싱된 심볼을 검색합니다. |
61
60
  | `--code-query <sql>` | 근거 인덱스에서 보수적 읽기 전용 SQL을 실행합니다. |
61
+
62
+ ### Topology Warnings
63
+
64
+ `--link-check`의 topology finding은 warning-only입니다. 그래서 bootstrap, update, release 흐름을 막지 않으면서 정리 방향을 알려줍니다.
65
+
66
+ | 코드 | 의미 |
67
+ | --- | --- |
68
+ | `hub-overload` | 사람이 관리하는 router 또는 meta 페이지가 너무 많은 위키 페이지를 링크하므로 분리하거나 범위를 좁혀야 합니다. |
69
+ | `weak-authority-route` | decision 또는 evidence 권위 신호가 있는 active canonical page가 generated auto-index routing으로만 도달 가능합니다. |
70
+ | `missing-evidence-link` | active canonical page가 source-backed claim을 하지만 source link 또는 `decision_ref`가 없습니다. |
71
+ | `stale-fanout` | 많이 링크된 active page가 topology-sensitive edit에 비해 너무 넓은 review trigger를 갖고 있습니다. |
package/docs/ko/usage.md CHANGED
@@ -69,7 +69,7 @@ npx project-librarian@latest install --scope project --agents all
69
69
  | 진단 전 라우팅 갱신 | "Project Librarian 라우팅을 갱신한 뒤 진단을 실행해줘." | `--doctor --fix` |
70
70
  | 위키 내용 검색 | "Project Librarian 위키에서 authentication decisions를 찾아줘." | `--query "authentication decisions"` |
71
71
  | 페이지 영향도 확인 | "decisions/release-policy의 Project Librarian 위키 영향도를 보여줘." | `--wiki-impact "decisions/release-policy"` |
72
- | 위키 그래프 생성 | "Project Librarian 위키 그래프 시각화를 생성해줘." | `--wiki-visualize` |
72
+ | 가까운 위키 맥락 찾기 | "canonical/project-brief의 Project Librarian wiki neighborhood를 보여줘." | `--wiki-neighborhood "canonical/project-brief"` |
73
73
  | 후보 메모 저장 | "이 내용을 Project Librarian 후보 메모로 저장해줘: <내용>." | `--capture-inbox --title "Candidate" --content "Details"` |
74
74
  | 세션 핸드오프 저장 | "현재 작업을 Project Librarian 세션 핸드오프로 저장해줘." | `--handoff-save --goal "..." --state "..." --next "..."` |
75
75
  | 핸드오프 보기 | "마지막 Project Librarian 세션 핸드오프를 보여줘." | `--handoff-show` |
@@ -160,9 +160,7 @@ MCP 서버 등록은 Claude Code(`.mcp.json`), Cursor(`.cursor/mcp.json`), Gemin
160
160
  6. `--refresh-index`는 새로 발견한 위키 페이지를 라우팅합니다. route가 많으면 `wiki/indexes/auto-*.md` 범위별 라우터로 나눕니다.
161
161
  7. `--code-index`는 `.project-wiki/` 아래 폐기 가능한 SQLite 근거 캐시를 만듭니다.
162
162
  8. `--code-report`, `--code-impact`, `--code-context-pack`, `--code-search-symbol`, `--code-query`는 계획 업데이트에 쓸 코드 기반 근거를 노출합니다.
163
- 9. 읽기 전용 위키 소비자는 경로와 frontmatter에서 사용자-facing page type을 파생하는 concept read model을 공유합니다.
164
- 10. 위키 생산자는 canonical markdown/YAML 스키마를 계속 쓰고, 진단/MCP/시각화 같은 읽기 전용 소비자는 파생 projection을 사용합니다.
165
- 11. `--wiki-visualize`는 데이터베이스나 서버 없이 `.project-wiki/` 아래 정적 그래프 산출물을 작성합니다.
166
- 12. 진단은 깨진 링크, 중복 route, orphan page, stale page, TL;DR 누락, 근거 공백, 마이그레이션 정책 위반을 보고합니다.
163
+ 9. 위키 생산자는 canonical markdown/YAML 스키마를 계속 쓰고, 진단/MCP 같은 읽기 전용 소비자는 원본 문서를 변경하지 않고 검사합니다.
164
+ 10. 진단은 깨진 링크, 중복 route, orphan page, topology warning, stale page, TL;DR 누락, 근거 공백, 마이그레이션 정책 위반을 보고합니다.
167
165
 
168
166
  마이그레이션은 검토 우선입니다. `--migrate`는 기존 `wiki/`를 `wiki_legacy*`로 보존하고, 양식 전용 파일은 건너뛰며, 여러 성격이 섞인 기존 페이지를 의미 단위로 나눕니다. 이후 각 단위를 분류해 `wiki/migration/` 아래 검토 파일을 작성합니다.
package/docs/usage.md CHANGED
@@ -67,7 +67,7 @@ Wiki setup and maintenance:
67
67
  | Refresh generated routing before diagnostics | "Refresh Project Librarian routing and then run diagnostics." | `--doctor --fix` |
68
68
  | Search project wiki content | "Search the Project Librarian wiki for authentication decisions." | `--query "authentication decisions"` |
69
69
  | Show backlinks and decision citations for a page | "Show Project Librarian wiki impact for decisions/release-policy." | `--wiki-impact "decisions/release-policy"` |
70
- | Generate a wiki graph visualizer | "Generate the Project Librarian wiki graph visualizer." | `--wiki-visualize` |
70
+ | Find nearby wiki context | "Show Project Librarian wiki neighborhood for canonical/project-brief." | `--wiki-neighborhood "canonical/project-brief"` |
71
71
  | Capture a candidate note | "Capture this as a Project Librarian candidate note: <details>." | `--capture-inbox --title "Candidate" --content "Details"` |
72
72
  | Save a session handoff | "Save a Project Librarian session handoff for the current work." | `--handoff-save --goal "..." --state "..." --next "..."` |
73
73
  | Resume from a handoff | "Show the last Project Librarian session handoff." | `--handoff-show` |
@@ -158,10 +158,8 @@ MCP server registration is a preservation-first merge into `mcpServers` for Clau
158
158
  6. `--refresh-index` routes newly discovered wiki pages; large route sets are split into `wiki/indexes/auto-*.md` scoped routers.
159
159
  7. `--code-index` creates a disposable SQLite evidence cache under `.project-wiki/`.
160
160
  8. `--code-report`, `--code-impact`, `--code-context-pack`, `--code-search-symbol`, and `--code-query` expose code-backed evidence for planning updates.
161
- 9. Read-only wiki consumers share a concept read model that derives user-facing page types from paths and frontmatter without rewriting the canonical wiki schema.
162
- 10. Wiki producers keep writing the canonical markdown/YAML schema, while read-only consumers such as diagnostics, MCP, and the visualizer use derived projections instead of mutating source documents.
163
- 11. `--wiki-visualize` writes a static graph artifact to `.project-wiki/`, reusing the wiki graph and concept read model instead of introducing a database or server.
164
- 12. Diagnostics report broken links, duplicate routes, orphan pages, stale pages, missing TL;DRs, evidence gaps, and migration policy violations.
161
+ 9. Wiki producers keep writing the canonical markdown/YAML schema, while read-only consumers such as diagnostics and MCP inspect source documents without mutating them.
162
+ 10. Diagnostics report broken links, duplicate routes, orphan pages, topology warnings, stale pages, missing TL;DRs, evidence gaps, and migration policy violations.
165
163
 
166
164
  Migration is intentionally review-first. `--migrate` preserves an existing `wiki/` as `wiki_legacy*`, skips form-only/template legacy files, splits mixed legacy pages into meaning units, classifies each unit through the document taxonomy, and writes review files under `wiki/migration/`:
167
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-librarian",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "description": "Create and maintain compact project context for humans and LLM coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -97,6 +97,6 @@
97
97
  "@sengac/tree-sitter-typescript": "^0.25.15"
98
98
  },
99
99
  "devDependencies": {
100
- "@types/node": "^22.20.0"
100
+ "@types/node": "^26.1.0"
101
101
  }
102
102
  }
@@ -1,49 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.conceptIdForFile = conceptIdForFile;
4
- exports.wikiConceptType = wikiConceptType;
5
- exports.conceptFromPage = conceptFromPage;
6
- exports.readWikiConcepts = readWikiConcepts;
7
- const wiki_files_1 = require("./wiki-files");
8
- const workspace_1 = require("./workspace");
9
- function conceptIdForFile(file) {
10
- return file.replace(/^wiki\//, "").replace(/\.(md|mdx)$/i, "");
11
- }
12
- function wikiConceptType(file, scope) {
13
- if (scope === "startup-router" || file === "wiki/startup.md")
14
- return "Startup Router";
15
- if (scope === "wiki-router" || file === "wiki/index.md" || /^wiki\/indexes\//.test(file))
16
- return "Wiki Router";
17
- if (scope === "project-canonical" || /^wiki\/canonical\//.test(file))
18
- return "Project Canonical Concept";
19
- if (scope === "project-decisions" || /^wiki\/decisions\//.test(file))
20
- return "Project Decision";
21
- if (scope === "source-summary" || /^wiki\/sources\//.test(file))
22
- return "Source Summary";
23
- if (scope === "wiki-meta" || /^wiki\/meta\//.test(file))
24
- return "Wiki Operations Concept";
25
- if (/^migration-/.test(scope) || /^wiki\/migration\//.test(file))
26
- return "Migration Ledger";
27
- if (scope === "inbox" || /^wiki\/inbox\//.test(file))
28
- return "Project Candidate";
29
- return "Wiki Concept";
30
- }
31
- function conceptFromPage(file, text) {
32
- const scope = (0, workspace_1.metadataValue)(text, "scope") || "-";
33
- const tldr = (0, wiki_files_1.firstTldrBullet)(text);
34
- return {
35
- budget: (0, workspace_1.metadataValue)(text, "read_budget") || "-",
36
- conceptId: conceptIdForFile(file),
37
- description: tldr || (0, wiki_files_1.compactSummary)(text),
38
- file,
39
- reviewTrigger: (0, workspace_1.metadataValue)(text, "review_trigger"),
40
- scope,
41
- status: (0, workspace_1.metadataValue)(text, "status") || "-",
42
- timestamp: (0, workspace_1.metadataValue)(text, "updated"),
43
- title: (0, wiki_files_1.wikiTitleForFile)(file, text),
44
- type: wikiConceptType(file, scope),
45
- };
46
- }
47
- function readWikiConcepts(files = (0, wiki_files_1.wikiMarkdownFiles)()) {
48
- return files.map((file) => conceptFromPage(file, (0, workspace_1.read)(file)));
49
- }
@@ -1,558 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.defaultWikiVisualizerOutput = void 0;
37
- exports.buildWikiVisualizerPayload = buildWikiVisualizerPayload;
38
- exports.writeWikiVisualizer = writeWikiVisualizer;
39
- const path = __importStar(require("node:path"));
40
- const wiki_concepts_1 = require("./wiki-concepts");
41
- const wiki_graph_1 = require("./wiki-graph");
42
- const wiki_files_1 = require("./wiki-files");
43
- const workspace_1 = require("./workspace");
44
- exports.defaultWikiVisualizerOutput = ".project-wiki/wiki-graph.html";
45
- function uniqueSorted(values) {
46
- return Array.from(new Set(values)).sort();
47
- }
48
- function visualizerEdges(graph, pages) {
49
- const linkEdges = graph.links
50
- .filter((link) => graph.files.has(link.normalizedTarget))
51
- .map((link) => ({ kind: "link", source: link.file, target: link.normalizedTarget }));
52
- const decisionRefEdges = [];
53
- for (const page of pages) {
54
- const ref = graph.outgoingDecisionRef.get(page.file);
55
- if (ref && graph.files.has(ref))
56
- decisionRefEdges.push({ kind: "decision_ref", source: page.file, target: ref });
57
- }
58
- return [...linkEdges, ...decisionRefEdges].sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.kind.localeCompare(b.kind));
59
- }
60
- function brokenLinksFor(graph, file) {
61
- return uniqueSorted((graph.outgoingLinks.get(file) ?? []).map((link) => link.normalizedTarget).filter((target) => !graph.files.has(target)));
62
- }
63
- function buildWikiVisualizerPayload(pages, generatedAt = new Date().toISOString()) {
64
- const graph = (0, wiki_graph_1.buildWikiGraph)(pages);
65
- const depths = (0, wiki_graph_1.wikiRouterDepths)(graph);
66
- const conceptByFile = new Map(pages.map((page) => [page.file, (0, wiki_concepts_1.conceptFromPage)(page.file, page.text)]));
67
- const edges = visualizerEdges(graph, pages);
68
- const nodes = pages.map((page) => {
69
- const concept = conceptByFile.get(page.file);
70
- const incomingCount = uniqueSorted((graph.incomingLinks.get(page.file) ?? []).map((link) => link.file)).length;
71
- const outgoingCount = uniqueSorted((graph.outgoingLinks.get(page.file) ?? []).map((link) => link.normalizedTarget).filter((target) => graph.files.has(target))).length;
72
- return {
73
- brokenLinks: brokenLinksFor(graph, page.file),
74
- budget: concept.budget,
75
- decisionRefCount: graph.incomingDecisionRefs.get(page.file)?.length ?? 0,
76
- description: concept.description,
77
- file: page.file,
78
- incomingCount,
79
- outgoingCount,
80
- reviewTrigger: concept.reviewTrigger,
81
- routerDepth: depths.get(page.file) ?? null,
82
- scope: concept.scope,
83
- status: concept.status,
84
- timestamp: concept.timestamp,
85
- title: concept.title,
86
- type: concept.type,
87
- };
88
- }).sort((a, b) => a.file.localeCompare(b.file));
89
- return {
90
- generatedAt,
91
- nodes,
92
- edges,
93
- summary: {
94
- brokenCount: nodes.filter((node) => node.brokenLinks.length > 0).length,
95
- edgeCount: edges.length,
96
- nodeCount: nodes.length,
97
- orphanCount: nodes.filter((node) => node.incomingCount === 0 && node.outgoingCount === 0).length,
98
- typeCount: new Set(nodes.map((node) => node.type)).size,
99
- unreachableCount: nodes.filter((node) => node.routerDepth === null).length,
100
- },
101
- };
102
- }
103
- function jsonForHtml(value) {
104
- return JSON.stringify(value).replace(/[<\u2028\u2029]/g, (ch) => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
105
- }
106
- function renderWikiVisualizerHtml(payload) {
107
- const payloadJson = jsonForHtml(payload);
108
- return `<!doctype html>
109
- <html lang="en">
110
- <head>
111
- <meta charset="utf-8">
112
- <meta name="viewport" content="width=device-width, initial-scale=1">
113
- <title>Project Librarian Wiki Graph</title>
114
- <style>
115
- :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f8fa; color: #16202a; }
116
- * { box-sizing: border-box; }
117
- body { margin: 0; min-height: 100vh; }
118
- header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid #d9dee7; background: #ffffff; }
119
- h1 { margin: 0; font-size: 18px; font-weight: 700; }
120
- main { display: grid; grid-template-columns: minmax(280px, 360px) 1fr minmax(280px, 380px); height: calc(100vh - 65px); }
121
- aside, section { min-width: 0; }
122
- .panel { border-right: 1px solid #d9dee7; background: #ffffff; padding: 16px; overflow: auto; }
123
- .detail { border-left: 1px solid #d9dee7; border-right: 0; }
124
- .summary { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #516070; }
125
- .summary span { padding: 4px 7px; border: 1px solid #cfd6e1; border-radius: 6px; background: #f4f6f9; }
126
- .summary span.alert { border-color: #e0a3a3; background: #fbecec; color: #9a3b3b; }
127
- label { display: block; margin: 14px 0 6px; font-size: 12px; font-weight: 700; color: #394756; }
128
- input, select { width: 100%; padding: 8px 10px; border: 1px solid #bdc7d4; border-radius: 6px; background: #ffffff; font: inherit; font-size: 14px; }
129
- .list-meta { margin-top: 16px; font-size: 12px; color: #667484; }
130
- .panel.nav { overflow: hidden; display: flex; flex-direction: column; }
131
- .list { margin-top: 10px; display: grid; gap: 8px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
132
- button.node { width: 100%; text-align: left; border: 1px solid #d8dee8; border-radius: 8px; background: #ffffff; padding: 10px; cursor: pointer; }
133
- button.node[aria-current="true"] { border-color: #2474b7; box-shadow: 0 0 0 2px rgba(36, 116, 183, 0.14); }
134
- .node-title { display: block; font-size: 14px; font-weight: 700; color: #152436; overflow-wrap: anywhere; }
135
- .node-meta { display: block; margin-top: 4px; font-size: 12px; color: #667484; overflow-wrap: anywhere; }
136
- .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
137
- .tag { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 999px; border: 1px solid; }
138
- .tag.unreachable { color: #9a3b3b; border-color: #e0a3a3; background: #fbecec; }
139
- .tag.orphan { color: #8a6d1b; border-color: #e3cd8f; background: #faf3df; }
140
- .tag.broken { color: #9a3b3b; border-color: #e0a3a3; background: #fbecec; }
141
- .graph-wrap { position: relative; overflow: hidden; background: #f7f8fa; }
142
- svg { width: 100%; height: calc(100vh - 65px); display: block; touch-action: none; cursor: grab; }
143
- svg.panning { cursor: grabbing; }
144
- .node { cursor: pointer; }
145
- .edge { stroke: #aeb8c5; stroke-width: 1.2; marker-end: url(#arrow); vector-effect: non-scaling-stroke; }
146
- .edge.decision_ref { stroke: #9b5c8f; stroke-dasharray: 5 4; }
147
- .edge.dim { opacity: 0.1; }
148
- .edge.hi { stroke: #17212b; stroke-width: 2; opacity: 1; }
149
- .edge.path { stroke: #d08700; stroke-width: 2.6; opacity: 1; }
150
- .point { stroke: #ffffff; stroke-width: 2; cursor: pointer; }
151
- .point.selected { stroke: #17212b; stroke-width: 3; }
152
- .point.unreachable { stroke: #cc4b4b; stroke-dasharray: 3 2; }
153
- .point.dim { opacity: 0.18; }
154
- .badge { fill: #cc4b4b; pointer-events: none; }
155
- .label { font-size: 11px; fill: #2b3848; paint-order: stroke; stroke: #f7f8fa; stroke-width: 4; stroke-linejoin: round; pointer-events: none; }
156
- .label.hidden { display: none; }
157
- .controls { position: absolute; right: 12px; top: 12px; display: flex; gap: 6px; }
158
- .controls button { width: 30px; height: 30px; border: 1px solid #cfd6e1; border-radius: 6px; background: #ffffff; cursor: pointer; font-size: 16px; line-height: 1; color: #2b3848; }
159
- .controls button:hover { border-color: #2474b7; }
160
- .topbar { position: absolute; top: 12px; left: 12px; z-index: 2; display: flex; align-items: center; gap: 8px; background: rgba(255, 255, 255, 0.93); border: 1px solid #d9dee7; border-radius: 8px; padding: 6px 10px; }
161
- .topbar label { display: inline; margin: 0; font-size: 12px; color: #394756; }
162
- .topbar select { width: auto; }
163
- .legend { position: absolute; left: 12px; bottom: 12px; max-width: calc(100% - 24px); background: rgba(255, 255, 255, 0.93); border: 1px solid #d9dee7; border-radius: 8px; padding: 8px 10px; display: flex; flex-wrap: wrap; gap: 6px 12px; font-size: 11px; color: #435367; }
164
- .legend .k { display: flex; align-items: center; gap: 5px; }
165
- .legend .sw { width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 0 1px #cfd6e1; }
166
- .legend .sw.ring { background: #ffffff; border: 1.5px dashed #cc4b4b; box-shadow: none; }
167
- .legend .sw.dot { background: #cc4b4b; }
168
- .empty { position: absolute; inset: 0; display: grid; place-items: center; color: #617083; pointer-events: none; }
169
- .empty[hidden] { display: none; }
170
- .detail h2 { margin: 0 0 8px; font-size: 18px; overflow-wrap: anywhere; }
171
- .detail dl { display: grid; grid-template-columns: 92px 1fr; gap: 8px 10px; margin: 16px 0; font-size: 13px; }
172
- .detail dt { font-weight: 700; color: #435367; }
173
- .detail dd { margin: 0; color: #1d2a36; overflow-wrap: anywhere; }
174
- .links { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 6px; }
175
- button.link { display: block; width: 100%; text-align: left; padding: 6px 8px; border: 1px solid #d9dee7; border-radius: 6px; background: #f8fafc; cursor: pointer; font: inherit; font-size: 13px; overflow-wrap: anywhere; }
176
- button.link:hover { border-color: #2474b7; }
177
- .kind { color: #7a8696; font-size: 11px; }
178
- .links li { padding: 6px 8px; border: 1px solid #e0a3a3; border-radius: 6px; background: #fbecec; font-size: 13px; overflow-wrap: anywhere; }
179
- @media (max-width: 960px) { main { grid-template-columns: 1fr; height: auto; } .panel, .detail { border: 0; border-bottom: 1px solid #d9dee7; max-height: 44vh; } .panel.nav { display: block; overflow: auto; } .list { flex: none; overflow: visible; } svg { height: 56vh; } }
180
- </style>
181
- </head>
182
- <body>
183
- <header>
184
- <h1>Project Librarian Wiki Graph</h1>
185
- <div class="summary" id="summary"></div>
186
- </header>
187
- <main>
188
- <aside class="panel nav">
189
- <label for="search">Search</label>
190
- <input id="search" autocomplete="off" placeholder="Title, path, type, scope">
191
- <label for="group">Group by</label>
192
- <select id="group">
193
- <option value="type">Concept type</option>
194
- <option value="section">Section / folder</option>
195
- <option value="depth">Router depth</option>
196
- <option value="community">Link community</option>
197
- </select>
198
- <label for="type">Type</label>
199
- <select id="type"></select>
200
- <label for="hygiene">Hygiene</label>
201
- <select id="hygiene">
202
- <option value="all">All pages</option>
203
- <option value="unreachable">Unreachable</option>
204
- <option value="orphan">Orphan (no links)</option>
205
- <option value="broken">Broken links</option>
206
- </select>
207
- <div class="list-meta" id="list-meta"></div>
208
- <div class="list" id="list"></div>
209
- </aside>
210
- <section class="graph-wrap">
211
- <div class="topbar">
212
- <label for="depth">Router depth</label>
213
- <select id="depth">
214
- <option value="all">All depths</option>
215
- <option value="reachable">Reachable</option>
216
- <option value="unreachable">Unreachable</option>
217
- </select>
218
- </div>
219
- <svg id="graph" role="img" aria-label="Wiki graph"></svg>
220
- <div class="controls">
221
- <button id="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
222
- <button id="zoom-out" title="Zoom out" aria-label="Zoom out">&#8722;</button>
223
- <button id="zoom-reset" title="Reset view" aria-label="Reset view">&#8853;</button>
224
- </div>
225
- <div class="legend" id="legend"></div>
226
- <div class="empty" id="empty" hidden>No matching nodes</div>
227
- </section>
228
- <aside class="panel detail" id="detail"></aside>
229
- </main>
230
- <script id="wiki-graph-data" type="application/json">${payloadJson}</script>
231
- <script>
232
- var payload = JSON.parse(document.getElementById("wiki-graph-data").textContent);
233
- var state = { selected: "", query: "", type: "all", depth: "all", group: "type", hygiene: "all" };
234
- var view = { x: 0, y: 0, k: 1 };
235
- var neighborSet = new Set();
236
- var dragging = false, last = null, down = null, moved = false, downOnPoint = false;
237
- var HUB = 8;
238
-
239
- function escapeHtml(value) { return String(value == null ? "" : value).replace(/[&<>"']/g, function (char) { return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]; }); }
240
- function escapeAttr(value) { return escapeHtml(value).replace(/"/g, "&quot;"); }
241
-
242
- var colorByType = new Map();
243
- var palette = ["#2f6f9f", "#5b7f3a", "#9a5f27", "#7b5aa6", "#bd4b4b", "#327d77", "#5d6f86", "#8b6f2b", "#6d7892"];
244
- function color(type) { if (!colorByType.has(type)) colorByType.set(type, palette[colorByType.size % palette.length]); return colorByType.get(type); }
245
- var sortedTypes = Array.from(new Set(payload.nodes.map(function (n) { return n.type; }))).sort();
246
- sortedTypes.forEach(function (t) { color(t); });
247
-
248
- var byFile = new Map(payload.nodes.map(function (n) { return [n.file, n]; }));
249
-
250
- function isUnreachable(n) { return n.routerDepth === null; }
251
- function isOrphan(n) { return n.incomingCount === 0 && n.outgoingCount === 0; }
252
- function isBroken(n) { return (n.brokenLinks || []).length > 0; }
253
- function degreeOf(n) { return n.incomingCount + n.outgoingCount; }
254
- function sectionOf(file) { var parts = String(file).split("/"); return parts.length > 2 ? parts[1] : "(root)"; }
255
-
256
- function computeCommunities(nodes, edges) {
257
- var adj = new Map();
258
- nodes.forEach(function (n) { adj.set(n.file, []); });
259
- edges.forEach(function (e) { if (adj.has(e.source) && adj.has(e.target)) { adj.get(e.source).push(e.target); adj.get(e.target).push(e.source); } });
260
- var label = new Map();
261
- nodes.forEach(function (n) { label.set(n.file, n.file); });
262
- var order = nodes.map(function (n) { return n.file; }).sort();
263
- for (var iter = 0; iter < 12; iter++) {
264
- var changed = false;
265
- for (var oi = 0; oi < order.length; oi++) {
266
- var f = order[oi];
267
- var nb = adj.get(f);
268
- if (!nb.length) continue;
269
- var counts = new Map();
270
- for (var j = 0; j < nb.length; j++) { var l = label.get(nb[j]); counts.set(l, (counts.get(l) || 0) + 1); }
271
- var pairs = Array.from(counts.keys()).sort().map(function (k) { return [k, counts.get(k)]; });
272
- var best = label.get(f), bestC = -1;
273
- for (var p = 0; p < pairs.length; p++) { if (pairs[p][1] > bestC) { bestC = pairs[p][1]; best = pairs[p][0]; } }
274
- if (best !== label.get(f)) { label.set(f, best); changed = true; }
275
- }
276
- if (!changed) break;
277
- }
278
- return label;
279
- }
280
- var community = computeCommunities(payload.nodes, payload.edges);
281
- function groupKey(n) {
282
- if (state.group === "section") return sectionOf(n.file);
283
- if (state.group === "depth") return isUnreachable(n) ? "unreachable" : "depth " + n.routerDepth;
284
- if (state.group === "community") return community.get(n.file) || "-";
285
- return n.type;
286
- }
287
-
288
- var summary = document.getElementById("summary");
289
- function chip(text, alert) { return '<span class="' + (alert ? "alert" : "") + '">' + escapeHtml(text) + "</span>"; }
290
- summary.innerHTML = [
291
- chip(payload.summary.nodeCount + " nodes", false),
292
- chip(payload.summary.edgeCount + " edges", false),
293
- chip(payload.summary.typeCount + " types", false),
294
- chip(payload.summary.unreachableCount + " unreachable", payload.summary.unreachableCount > 0),
295
- chip(payload.summary.orphanCount + " orphan", payload.summary.orphanCount > 0),
296
- chip(payload.summary.brokenCount + " broken", payload.summary.brokenCount > 0),
297
- chip("Generated " + payload.generatedAt, false)
298
- ].join("");
299
-
300
- var typeSelect = document.getElementById("type");
301
- typeSelect.innerHTML = '<option value="all">All types</option>' + sortedTypes.map(function (type) { return '<option value="' + escapeAttr(type) + '">' + escapeHtml(type) + "</option>"; }).join("");
302
-
303
- var legend = document.getElementById("legend");
304
- legend.innerHTML = sortedTypes.map(function (t) { return '<span class="k"><span class="sw" style="background:' + color(t) + '"></span>' + escapeHtml(t) + "</span>"; }).join("") +
305
- '<span class="k"><span class="sw ring"></span>unreachable</span>' +
306
- '<span class="k"><span class="sw dot"></span>broken link</span>';
307
-
308
- document.getElementById("search").addEventListener("input", function (event) { state.query = event.target.value.toLowerCase(); render(); });
309
- typeSelect.addEventListener("change", function (event) { state.type = event.target.value; render(); });
310
- document.getElementById("depth").addEventListener("change", function (event) { state.depth = event.target.value; render(); });
311
- document.getElementById("group").addEventListener("change", function (event) { state.group = event.target.value; view = { x: 0, y: 0, k: 1 }; render(); });
312
- document.getElementById("hygiene").addEventListener("change", function (event) { state.hygiene = event.target.value; render(); });
313
- document.getElementById("zoom-in").addEventListener("click", function () { zoomAtCenter(1.25); });
314
- document.getElementById("zoom-out").addEventListener("click", function () { zoomAtCenter(1 / 1.25); });
315
- document.getElementById("zoom-reset").addEventListener("click", function () { view = { x: 0, y: 0, k: 1 }; applyView(); });
316
-
317
- function matchHygiene(n) {
318
- if (state.hygiene === "unreachable") return isUnreachable(n);
319
- if (state.hygiene === "orphan") return isOrphan(n);
320
- if (state.hygiene === "broken") return isBroken(n);
321
- return true;
322
- }
323
- function filteredNodes() {
324
- return payload.nodes.filter(function (node) {
325
- var haystack = [node.file, node.title, node.type, node.scope, node.description].join(" ").toLowerCase();
326
- var typeOk = state.type === "all" || node.type === state.type;
327
- var depthOk = state.depth === "all" || (state.depth === "reachable" ? node.routerDepth !== null : node.routerDepth === null);
328
- return typeOk && depthOk && matchHygiene(node) && (!state.query || haystack.includes(state.query));
329
- });
330
- }
331
-
332
- function ringLayout(nodes, width, height) {
333
- var centerX = width / 2, centerY = height / 2;
334
- var byDepth = new Map();
335
- nodes.forEach(function (node) { var key = node.routerDepth === null ? "unreachable" : String(node.routerDepth); byDepth.set(key, (byDepth.get(key) || []).concat([node])); });
336
- var positions = new Map();
337
- var rings = Array.from(byDepth.keys()).sort(function (a, b) { return (a === "unreachable" ? 999 : Number(a)) - (b === "unreachable" ? 999 : Number(b)); });
338
- rings.forEach(function (ring, ringIndex) {
339
- var items = byDepth.get(ring);
340
- var radius = ring === "0" ? 0 : Math.min(width, height) * (0.13 + ringIndex * 0.09);
341
- items.forEach(function (node, index) {
342
- var angle = items.length === 1 ? -Math.PI / 2 : (Math.PI * 2 * index) / items.length - Math.PI / 2;
343
- positions.set(node.file, { x: centerX + Math.cos(angle) * radius, y: centerY + Math.sin(angle) * radius });
344
- });
345
- });
346
- return positions;
347
- }
348
- function clusterLayout(nodes, width, height) {
349
- var groups = new Map();
350
- nodes.forEach(function (n) { var k = groupKey(n); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(n); });
351
- var keys = Array.from(groups.keys()).sort();
352
- var cx = width / 2, cy = height / 2;
353
- var R = Math.min(width, height) * 0.38;
354
- var positions = new Map();
355
- keys.forEach(function (k, gi) {
356
- var items = groups.get(k).slice().sort(function (a, b) { return a.file < b.file ? -1 : 1; });
357
- var ga = keys.length === 1 ? 0 : (Math.PI * 2 * gi) / keys.length - Math.PI / 2;
358
- var gx = keys.length === 1 ? cx : cx + Math.cos(ga) * R;
359
- var gy = keys.length === 1 ? cy : cy + Math.sin(ga) * R;
360
- items.forEach(function (n, i) { var a = i * 2.399963229; var rr = 15 * Math.sqrt(i); positions.set(n.file, { x: gx + Math.cos(a) * rr, y: gy + Math.sin(a) * rr }); });
361
- });
362
- return positions;
363
- }
364
- function layout(nodes, width, height) { return state.group === "depth" ? ringLayout(nodes, width, height) : clusterLayout(nodes, width, height); }
365
-
366
- function rootFile() { var r = payload.nodes.find(function (n) { return n.routerDepth === 0; }); return r ? r.file : "wiki/startup.md"; }
367
- function pathTo(target) {
368
- var root = rootFile();
369
- if (!target || !byFile.has(target) || target === root) return [];
370
- var adj = new Map();
371
- payload.edges.forEach(function (e) { if (!adj.has(e.source)) adj.set(e.source, []); adj.get(e.source).push(e.target); });
372
- var queue = [root], prev = new Map([[root, null]]);
373
- while (queue.length) { var c = queue.shift(); if (c === target) break; var nx = adj.get(c) || []; for (var i = 0; i < nx.length; i++) { if (!prev.has(nx[i])) { prev.set(nx[i], c); queue.push(nx[i]); } } }
374
- if (!prev.has(target)) return [];
375
- var pathNodes = [], cur = target;
376
- while (cur != null) { pathNodes.unshift(cur); cur = prev.get(cur); }
377
- return pathNodes;
378
- }
379
-
380
- function applyView() {
381
- var vp = document.getElementById("viewport");
382
- if (!vp) return;
383
- vp.setAttribute("transform", "translate(" + view.x + "," + view.y + ") scale(" + view.k + ")");
384
- var inv = 1 / view.k;
385
- var groups = vp.querySelectorAll("g.node");
386
- for (var i = 0; i < groups.length; i++) {
387
- var g = groups[i];
388
- g.setAttribute("transform", "translate(" + g.getAttribute("data-x") + "," + g.getAttribute("data-y") + ") scale(" + inv + ")");
389
- }
390
- updateLOD();
391
- }
392
- function updateLOD() {
393
- var showAll = view.k >= 1.8;
394
- var labels = document.querySelectorAll("text.label");
395
- for (var i = 0; i < labels.length; i++) {
396
- var t = labels[i];
397
- var deg = Number(t.getAttribute("data-deg")) || 0;
398
- var f = t.getAttribute("data-file");
399
- var keep = showAll || deg >= HUB || f === state.selected || neighborSet.has(f);
400
- t.classList.toggle("hidden", !keep);
401
- }
402
- }
403
- function zoomAtCenter(factor) {
404
- var svg = document.getElementById("graph");
405
- var rect = svg.getBoundingClientRect();
406
- zoomAt(rect.width / 2, rect.height / 2, factor);
407
- }
408
- function zoomAt(mx, my, factor) {
409
- var nk = Math.min(6, Math.max(0.3, view.k * factor));
410
- var r = nk / view.k;
411
- view.x = mx - r * (mx - view.x);
412
- view.y = my - r * (my - view.y);
413
- view.k = nk;
414
- applyView();
415
- }
416
-
417
- function renderGraph(nodes) {
418
- var svg = document.getElementById("graph");
419
- var empty = document.getElementById("empty");
420
- var width = svg.clientWidth || 900;
421
- var height = svg.clientHeight || 640;
422
- var files = new Set(nodes.map(function (node) { return node.file; }));
423
- var positions = layout(nodes, width, height);
424
- var edges = payload.edges.filter(function (edge) { return files.has(edge.source) && files.has(edge.target); });
425
- empty.hidden = nodes.length > 0;
426
-
427
- var focusOn = state.selected && byFile.has(state.selected) && files.has(state.selected);
428
- neighborSet = new Set();
429
- var pathSet = new Set();
430
- if (focusOn) {
431
- edges.forEach(function (e) { if (e.source === state.selected) neighborSet.add(e.target); if (e.target === state.selected) neighborSet.add(e.source); });
432
- neighborSet.add(state.selected);
433
- var p = pathTo(state.selected);
434
- for (var i = 0; i + 1 < p.length; i++) pathSet.add(p[i] + "->" + p[i + 1]);
435
- }
436
-
437
- var defs = '<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#aeb8c5"></path></marker></defs>';
438
- var edgeMarkup = edges.map(function (edge) {
439
- var source = positions.get(edge.source), target = positions.get(edge.target);
440
- if (!source || !target) return "";
441
- var cls = "edge " + edge.kind;
442
- if (focusOn) {
443
- if (pathSet.has(edge.source + "->" + edge.target)) cls += " path";
444
- else if (edge.source === state.selected || edge.target === state.selected) cls += " hi";
445
- else cls += " dim";
446
- }
447
- return '<line class="' + cls + '" x1="' + source.x + '" y1="' + source.y + '" x2="' + target.x + '" y2="' + target.y + '"></line>';
448
- }).join("");
449
- var nodeMarkup = nodes.map(function (node) {
450
- var point = positions.get(node.file);
451
- if (!point) return "";
452
- var cls = "point";
453
- if (node.file === state.selected) cls += " selected";
454
- if (isUnreachable(node)) cls += " unreachable";
455
- if (focusOn && !neighborSet.has(node.file)) cls += " dim";
456
- var markup = '<g class="node" data-file="' + escapeAttr(node.file) + '" data-x="' + point.x + '" data-y="' + point.y + '">';
457
- markup += '<circle class="' + cls + '" r="8" fill="' + color(node.type) + '"></circle>';
458
- if (isBroken(node)) markup += '<circle class="badge" cx="6" cy="-6" r="3"></circle>';
459
- markup += '<text class="label" data-file="' + escapeAttr(node.file) + '" data-deg="' + degreeOf(node) + '" x="11" y="4">' + escapeHtml(node.title.slice(0, 34)) + "</text></g>";
460
- return markup;
461
- }).join("");
462
- svg.innerHTML = defs + '<g id="viewport">' + edgeMarkup + nodeMarkup + "</g>";
463
- svg.querySelectorAll("g.node").forEach(function (g) { g.addEventListener("click", function () { state.selected = g.dataset.file; render(); }); });
464
- applyView();
465
- }
466
-
467
- function tagsFor(node) {
468
- var tags = "";
469
- if (isUnreachable(node)) tags += '<span class="tag unreachable">unreachable</span>';
470
- if (isOrphan(node)) tags += '<span class="tag orphan">orphan</span>';
471
- if (isBroken(node)) tags += '<span class="tag broken">broken ' + node.brokenLinks.length + "</span>";
472
- return tags ? '<span class="tags">' + tags + "</span>" : "";
473
- }
474
- function renderList(nodes) {
475
- var list = document.getElementById("list");
476
- var meta = document.getElementById("list-meta");
477
- meta.textContent = nodes.length + " / " + payload.nodes.length + " pages" + (nodes.length > 200 ? " (showing first 200)" : "");
478
- list.innerHTML = nodes.slice(0, 200).map(function (node) {
479
- return '<button class="node" aria-current="' + (node.file === state.selected ? "true" : "false") + '" data-file="' + escapeAttr(node.file) + '"><span class="node-title">' + escapeHtml(node.title) + '</span><span class="node-meta">' + escapeHtml(node.type + " - " + node.file) + "</span>" + tagsFor(node) + "</button>";
480
- }).join("");
481
- list.querySelectorAll("button.node").forEach(function (button) { button.addEventListener("click", function () { state.selected = button.dataset.file; render(); }); });
482
- }
483
- function renderDetail() {
484
- var node = byFile.get(state.selected) || payload.nodes[0];
485
- var detail = document.getElementById("detail");
486
- if (!node) { detail.innerHTML = ""; return; }
487
- function linkRow(file, kind) { var n = byFile.get(file); return '<button class="link" data-file="' + escapeAttr(file) + '">' + escapeHtml(n ? n.title : file) + ' <span class="kind">' + escapeHtml(kind) + "</span></button>"; }
488
- var incoming = payload.edges.filter(function (edge) { return edge.target === node.file; }).map(function (edge) { return linkRow(edge.source, edge.kind); });
489
- var outgoing = payload.edges.filter(function (edge) { return edge.source === node.file; }).map(function (edge) { return linkRow(edge.target, edge.kind); });
490
- var brokenHtml = (node.brokenLinks || []).length ? '<strong>Broken links</strong><ul class="links">' + node.brokenLinks.map(function (b) { return "<li>" + escapeHtml(b) + "</li>"; }).join("") + "</ul>" : "";
491
- detail.innerHTML = "<h2>" + escapeHtml(node.title) + "</h2>" + tagsFor(node) + "<p>" + escapeHtml(node.description || "No description") + "</p>" +
492
- "<dl>" +
493
- "<dt>Path</dt><dd>" + escapeHtml(node.file) + "</dd>" +
494
- "<dt>Type</dt><dd>" + escapeHtml(node.type) + "</dd>" +
495
- "<dt>Scope</dt><dd>" + escapeHtml(node.scope) + "</dd>" +
496
- "<dt>Status</dt><dd>" + escapeHtml(node.status) + "</dd>" +
497
- "<dt>Budget</dt><dd>" + escapeHtml(node.budget) + "</dd>" +
498
- "<dt>Router</dt><dd>" + escapeHtml(node.routerDepth === null ? "unreachable" : "depth " + node.routerDepth) + "</dd>" +
499
- "<dt>Updated</dt><dd>" + escapeHtml(node.timestamp || "-") + "</dd>" +
500
- "<dt>Trigger</dt><dd>" + escapeHtml(node.reviewTrigger || "-") + "</dd>" +
501
- "</dl>" +
502
- "<strong>Incoming</strong><ul class=\\"links\\">" + (incoming.length ? incoming.join("") : "<li>none</li>") + "</ul>" +
503
- "<strong>Outgoing</strong><ul class=\\"links\\">" + (outgoing.length ? outgoing.join("") : "<li>none</li>") + "</ul>" +
504
- brokenHtml;
505
- detail.querySelectorAll("button.link").forEach(function (button) { button.addEventListener("click", function () { state.selected = button.dataset.file; render(); }); });
506
- }
507
- function render() {
508
- var nodes = filteredNodes();
509
- if (state.selected && !nodes.some(function (node) { return node.file === state.selected; })) state.selected = "";
510
- renderList(nodes);
511
- renderGraph(nodes);
512
- renderDetail();
513
- }
514
-
515
- var svgEl = document.getElementById("graph");
516
- svgEl.addEventListener("wheel", function (e) { e.preventDefault(); var rect = svgEl.getBoundingClientRect(); zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.12 : 1 / 1.12); }, { passive: false });
517
- svgEl.addEventListener("pointerdown", function (e) {
518
- down = { x: e.clientX, y: e.clientY }; moved = false;
519
- downOnPoint = !!(e.target.closest && e.target.closest("g.node"));
520
- if (!downOnPoint) { dragging = true; last = { x: e.clientX, y: e.clientY }; svgEl.classList.add("panning"); }
521
- });
522
- svgEl.addEventListener("pointermove", function (e) {
523
- if (down && (Math.abs(e.clientX - down.x) + Math.abs(e.clientY - down.y)) > 4) moved = true;
524
- if (dragging) { view.x += e.clientX - last.x; view.y += e.clientY - last.y; last = { x: e.clientX, y: e.clientY }; applyView(); }
525
- });
526
- svgEl.addEventListener("pointerup", function () {
527
- if (dragging) { dragging = false; svgEl.classList.remove("panning"); }
528
- if (!moved && !downOnPoint && state.selected) { state.selected = ""; render(); }
529
- down = null;
530
- });
531
- svgEl.addEventListener("pointerleave", function () { if (dragging) { dragging = false; svgEl.classList.remove("panning"); } });
532
- window.addEventListener("resize", render);
533
- render();
534
- </script>
535
- </body>
536
- </html>
537
- `;
538
- }
539
- function normalizedVisualizerOutput(outputPath) {
540
- const normalized = (0, workspace_1.normalizePath)(path.normalize(outputPath || exports.defaultWikiVisualizerOutput));
541
- if (path.isAbsolute(outputPath) || normalized.startsWith("../") || normalized === "..") {
542
- throw new Error(`--wiki-visualize-out must be a repository-relative path under .project-wiki/: ${outputPath}`);
543
- }
544
- if (!normalized.startsWith(".project-wiki/")) {
545
- throw new Error(`--wiki-visualize-out must stay under .project-wiki/: ${outputPath}`);
546
- }
547
- return normalized;
548
- }
549
- function writeWikiVisualizer(outputPath = exports.defaultWikiVisualizerOutput) {
550
- const files = (0, wiki_files_1.wikiMarkdownFiles)();
551
- if (files.length === 0)
552
- throw new Error("wiki directory has no markdown files; run Project Librarian before --wiki-visualize.");
553
- const pages = files.map((file) => ({ file, text: (0, workspace_1.read)(file) }));
554
- const payload = buildWikiVisualizerPayload(pages);
555
- const relativeOutput = normalizedVisualizerOutput(outputPath);
556
- (0, workspace_1.write)(relativeOutput, renderWikiVisualizerHtml(payload));
557
- return (0, workspace_1.normalizePath)(path.relative(workspace_1.root, path.join(workspace_1.root, relativeOutput)));
558
- }