@zosmaai/pi-llm-wiki 0.11.4 → 0.11.6

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.
Files changed (62) hide show
  1. package/README.de.md +8 -0
  2. package/README.es.md +8 -0
  3. package/README.fr.md +8 -0
  4. package/README.hi.md +8 -0
  5. package/README.ja.md +8 -0
  6. package/README.ko.md +8 -0
  7. package/README.md +8 -0
  8. package/README.pt.md +8 -0
  9. package/README.ru.md +8 -0
  10. package/README.zh.md +8 -0
  11. package/assets/wiki-dashboard.png +0 -0
  12. package/commands/wiki-ingest.md +1 -0
  13. package/commands/wiki-req.md +1 -0
  14. package/commands/wiki-retro.md +1 -0
  15. package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
  16. package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
  17. package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
  18. package/dist/extensions/llm-wiki/lib/host.js +21 -1
  19. package/dist/extensions/llm-wiki/lib/ingest-worker.js +44 -20
  20. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  21. package/dist/extensions/llm-wiki/lib/knowledge-links.js +133 -27
  22. package/dist/extensions/llm-wiki/lib/metadata.js +6 -6
  23. package/dist/extensions/llm-wiki/lib/observation.js +22 -3
  24. package/dist/extensions/llm-wiki/lib/retro.js +38 -4
  25. package/dist/extensions/llm-wiki/lib/runtime.js +2 -2
  26. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  27. package/dist/extensions/llm-wiki/lib/task-config.js +100 -1
  28. package/dist/extensions/llm-wiki/lib/tools.js +47 -8
  29. package/dist/mcp/index.js +2 -1
  30. package/dist/mcp/operations.js +21 -2
  31. package/docs/api.md +24 -1
  32. package/docs/commands.md +6 -1
  33. package/docs/configuration.md +11 -0
  34. package/docs/obsidian.md +6 -6
  35. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  36. package/docs/superpowers/plans/2026-08-27-wikilink-resolver-normalization.md +735 -0
  37. package/docs/superpowers/plans/2026-08-29-wikilink-gate-ensure-page-retro.md +642 -0
  38. package/docs/superpowers/plans/2026-08-29-wikilink-write-validation.md +695 -0
  39. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  40. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  41. package/extensions/llm-wiki/index.ts +4 -0
  42. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  43. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  44. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  45. package/extensions/llm-wiki/lib/host.ts +21 -1
  46. package/extensions/llm-wiki/lib/ingest-worker.ts +64 -27
  47. package/extensions/llm-wiki/lib/knowledge-document.ts +21 -2
  48. package/extensions/llm-wiki/lib/knowledge-links.ts +208 -35
  49. package/extensions/llm-wiki/lib/metadata.ts +10 -6
  50. package/extensions/llm-wiki/lib/observation.ts +23 -3
  51. package/extensions/llm-wiki/lib/retro.ts +48 -4
  52. package/extensions/llm-wiki/lib/runtime.ts +2 -2
  53. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  54. package/extensions/llm-wiki/lib/task-config.ts +138 -0
  55. package/extensions/llm-wiki/lib/tools.ts +62 -8
  56. package/mcp/index.ts +12 -1
  57. package/mcp/operations.ts +32 -2
  58. package/package.json +4 -4
  59. package/prompts/wiki-ingest.md +1 -0
  60. package/prompts/wiki-req.md +1 -0
  61. package/prompts/wiki-retro.md +1 -0
  62. package/skills/llm-wiki/SKILL.md +11 -1
@@ -0,0 +1,735 @@
1
+ # Wikilink Resolver Normalization Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use /skill:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Fix false "missing page" diagnostics by making wikilink resolution lenient — normalize case, whitespace, slug, and bare titles against the page registry, dropping unresolved links from ~271 to ~104 (truly missing).
6
+
7
+ **Architecture:** Introduce a `WikilinkIndex` (precomputed normalized lookup maps from the page registry) and a `resolveWikilink` function that tries exact match → normalized full-path match → bare-title basename match (with ambiguity detection). Replace the case-sensitive `Set.has()` check in `buildResolvedBacklinks` with index lookups. Both callers (metadata rebuild, wiki_lint) build the index once and pass it in.
8
+
9
+ **Tech Stack:** TypeScript (ES2022, ESM), Vitest, Biome
10
+
11
+ **Roadmap:** None
12
+
13
+ **Phase:** Single-plan implementation
14
+
15
+ ---
16
+
17
+ ### File Map
18
+
19
+ | File | Change |
20
+ |---|---|
21
+ | `extensions/llm-wiki/lib/knowledge-document.ts:22` | Add `"link_ambiguous"` to `DiagnosticCode` union |
22
+ | `extensions/llm-wiki/lib/knowledge-links.ts:3-8` | Add `slugify` import from `./utils.js` |
23
+ | `extensions/llm-wiki/lib/knowledge-links.ts` (new, after line 108) | Add `WikilinkIndex` interface, `buildWikilinkIndex`, `resolveWikilink`, `WikilinkResolution` |
24
+ | `extensions/llm-wiki/lib/knowledge-links.ts:247-310` | Rewrite `buildResolvedBacklinks` body (same signature shape, new `index` param) |
25
+ | `extensions/llm-wiki/lib/metadata.ts:14` | Add `buildWikilinkIndex, WikilinkIndex` to import from `knowledge-links.js` |
26
+ | `extensions/llm-wiki/lib/metadata.ts:89` | Replace `knownIds` set with `buildWikilinkIndex(documents.map(...))` |
27
+ | `extensions/llm-wiki/lib/metadata.ts:248-271` | Update `buildBacklinks` signature: `index: WikilinkIndex` instead of `knownIds: Set<string>` |
28
+ | `extensions/llm-wiki/lib/tools.ts:14` | Add `buildWikilinkIndex` to import from `knowledge-links.js` |
29
+ | `extensions/llm-wiki/lib/tools.ts:874-945` | Replace `knownIds` set with `buildWikilinkIndex(pages.map(...))`, count ambiguous links |
30
+ | `test/knowledge-links.test.ts:3` | Add `buildWikilinkIndex` to imports |
31
+ | `test/knowledge-links.test.ts` (all tests calling `buildResolvedBacklinks`) | Update all call sites to pass `buildWikilinkIndex(known)` instead of `known` |
32
+ | `test/knowledge-links.test.ts` (new tests) | Add normalization resolution tests |
33
+
34
+ ---
35
+
36
+ ### Task 1: Add `link_ambiguous` diagnostic code
37
+
38
+ **Files:**
39
+ - Modify: `extensions/llm-wiki/lib/knowledge-document.ts:22-23`
40
+
41
+ - [x] **Step 1: Add the new code to the union type**
42
+
43
+ In `knowledge-document.ts`, the `DiagnosticCode` union is at line 22. Add `"link_ambiguous"` after `"link_unresolved"`:
44
+
45
+ ```ts
46
+ | "link_path_escape"
47
+ | "link_unresolved"
48
+ | "link_ambiguous"
49
+ | "event_source_missing"
50
+ ```
51
+
52
+ - [x] **Step 2: Run typecheck to confirm no breakage**
53
+
54
+ Run: `pnpm typecheck`
55
+ Expected: PASS (only adding to a union, no callers yet)
56
+
57
+ - [x] **Step 3: Commit**
58
+
59
+ ```bash
60
+ git add extensions/llm-wiki/lib/knowledge-document.ts
61
+ git commit -m "feat(wiki): add link_ambiguous diagnostic code (#172)"
62
+ ```
63
+
64
+ ---
65
+
66
+ ### Task 2: Add WikilinkIndex, buildWikilinkIndex, and resolveWikilink
67
+
68
+ **Files:**
69
+ - Modify: `extensions/llm-wiki/lib/knowledge-links.ts:3-8` (add import)
70
+ - Modify: `extensions/llm-wiki/lib/knowledge-links.ts` (new exports after line 108)
71
+
72
+ - [x] **Step 1: Add `slugify` import**
73
+
74
+ At the top of `knowledge-links.ts`, after the existing imports (line 3–8), add:
75
+
76
+ ```ts
77
+ import { slugify } from "./utils.js";
78
+ ```
79
+
80
+ - [x] **Step 2: Write the failing test**
81
+
82
+ Open `test/knowledge-links.test.ts` and add at the top of the imports (line 3):
83
+
84
+ ```ts
85
+ import {
86
+ buildResolvedBacklinks,
87
+ buildWikilinkIndex,
88
+ extractKnowledgeLinks,
89
+ extractLegacyWikilinks,
90
+ } from "../extensions/llm-wiki/lib/knowledge-links.js";
91
+ ```
92
+
93
+ Then add a new test block after the existing `describe("knowledge links")` block:
94
+
95
+ ```ts
96
+ describe("resolveWikilink normalization", () => {
97
+ it("resolves bare title against a unique page by slugified basename", () => {
98
+ const index = buildWikilinkIndex(["entities/zosma-harness", "concepts/other"]);
99
+ const result = buildResolvedBacklinks(
100
+ "sources/some-source",
101
+ "[[zosma harness]]",
102
+ index,
103
+ );
104
+ expect(result.targets).toEqual(["entities/zosma-harness"]);
105
+ expect(result.unresolved).toEqual([]);
106
+ });
107
+
108
+ it("resolves folder-qualified link with case/space drift", () => {
109
+ const index = buildWikilinkIndex(["concepts/attention-is-all-you-need"]);
110
+ const result = buildResolvedBacklinks(
111
+ "sources/some-source",
112
+ "[[concepts/Attention Is All You Need]]",
113
+ index,
114
+ );
115
+ expect(result.targets).toEqual(["concepts/attention-is-all-you-need"]);
116
+ expect(result.unresolved).toEqual([]);
117
+ });
118
+
119
+ it("reports ambiguous when bare title matches multiple pages", () => {
120
+ const index = buildWikilinkIndex(["entities/ibm", "concepts/ibm"]);
121
+ const result = buildResolvedBacklinks(
122
+ "sources/some-source",
123
+ "[[ibm]]",
124
+ index,
125
+ );
126
+ expect(result.targets).toEqual([]);
127
+ expect(result.unresolved).toEqual([]);
128
+ expect(result.diagnostics).toHaveLength(1);
129
+ expect(result.diagnostics[0].code).toBe("link_ambiguous");
130
+ expect(result.diagnostics[0].message).toContain("entities/ibm");
131
+ expect(result.diagnostics[0].message).toContain("concepts/ibm");
132
+ });
133
+
134
+ it("prefers exact match over normalized match", () => {
135
+ const index = buildWikilinkIndex(["entities/ibm", "concepts/ibm"]);
136
+ // Exact match wins even though normalized would be ambiguous for the bare slug
137
+ const result = buildResolvedBacklinks(
138
+ "sources/some-source",
139
+ "[[entities/ibm]]",
140
+ index,
141
+ );
142
+ expect(result.targets).toEqual(["entities/ibm"]);
143
+ expect(result.unresolved).toEqual([]);
144
+ expect(result.diagnostics).toEqual([]);
145
+ });
146
+
147
+ it("resolves link with case drift in folder-qualified path", () => {
148
+ const index = buildWikilinkIndex(["entities/google-brain"]);
149
+ const result = buildResolvedBacklinks(
150
+ "sources/some-source",
151
+ "[[Entities/Google-Brain]]",
152
+ index,
153
+ );
154
+ expect(result.targets).toEqual(["entities/google-brain"]);
155
+ expect(result.unresolved).toEqual([]);
156
+ });
157
+
158
+ it("resolves slugified trailing slash variant", () => {
159
+ const index = buildWikilinkIndex(["concepts/retrieval-augmented-generation"]);
160
+ const result = buildResolvedBacklinks(
161
+ "sources/some-source",
162
+ "[[concepts/Retrieval Augmented Generation]]",
163
+ index,
164
+ );
165
+ expect(result.targets).toEqual(["concepts/retrieval-augmented-generation"]);
166
+ });
167
+ });
168
+ ```
169
+
170
+ - [x] **Step 3: Run test to verify it fails**
171
+
172
+ Run: `pnpm vitest run test/knowledge-links.test.ts 2>&1 | tail -25`
173
+ Expected: FAIL with `buildWikilinkIndex is not exported` or `is not a function`
174
+
175
+ - [x] **Step 4: Implement the new exports**
176
+
177
+ Add to `knowledge-links.ts`, after the `extractLegacyWikilinks` function (after line ~122), before `resolveMarkdownTarget`:
178
+
179
+ ```ts
180
+ // ── Wikilink normalization ──────────────────────────────────────────
181
+
182
+ export interface WikilinkIndex {
183
+ /** NFC-normalized id → canonical id. */
184
+ byExact: Map<string, string>;
185
+ /** Slugified full path → matching canonical ids. */
186
+ byNormPath: Map<string, string[]>;
187
+ /** Slugified basename (no folder) → matching canonical ids. */
188
+ byNormSlug: Map<string, string[]>;
189
+ }
190
+
191
+ export function buildWikilinkIndex(ids: Iterable<string>): WikilinkIndex {
192
+ const byExact = new Map<string, string>();
193
+ const byNormPath = new Map<string, string[]>();
194
+ const byNormSlug = new Map<string, string[]>();
195
+
196
+ function push<K>(map: Map<K, string[]>, key: K, value: string): void {
197
+ const existing = map.get(key);
198
+ if (existing) existing.push(value);
199
+ else map.set(key, [value]);
200
+ }
201
+
202
+ for (const id of ids) {
203
+ byExact.set(id.normalize("NFC"), id);
204
+ const segments = id.split("/");
205
+ const normFull = segments.map((s) => slugify(s)).join("/");
206
+ const normBase = slugify(segments[segments.length - 1]);
207
+ push(byNormPath, normFull, id);
208
+ push(byNormSlug, normBase, id);
209
+ }
210
+
211
+ return { byExact, byNormPath, byNormSlug };
212
+ }
213
+
214
+ export type WikilinkResolution =
215
+ | { kind: "resolved"; id: string }
216
+ | { kind: "ambiguous"; target: string; candidates: string[] }
217
+ | { kind: "missing"; target: string };
218
+
219
+ export function resolveWikilink(
220
+ target: string,
221
+ index: WikilinkIndex,
222
+ ): WikilinkResolution {
223
+ const cleaned = target.trim().replace(/\\$/, "");
224
+ if (!cleaned) return { kind: "missing", target: "" };
225
+
226
+ // Fast path: exact NFC match
227
+ const exact = index.byExact.get(cleaned.normalize("NFC"));
228
+ if (exact) return { kind: "resolved", id: exact };
229
+
230
+ // Normalized full path (fixes case/space/slug drift in folder-qualified links)
231
+ const normFull = cleaned
232
+ .split("/")
233
+ .map((s) => slugify(s))
234
+ .join("/");
235
+ const pathHits = index.byNormPath.get(normFull);
236
+ if (pathHits && pathHits.length === 1) return { kind: "resolved", id: pathHits[0] };
237
+ if (pathHits && pathHits.length > 1)
238
+ return { kind: "ambiguous", target: cleaned, candidates: pathHits };
239
+
240
+ // Bare title: match by slugified basename (handles [[zosma harness]] → entities/zosma-harness)
241
+ if (!cleaned.includes("/")) {
242
+ const baseHits = index.byNormSlug.get(slugify(cleaned));
243
+ if (baseHits && baseHits.length === 1) return { kind: "resolved", id: baseHits[0] };
244
+ if (baseHits && baseHits.length > 1)
245
+ return { kind: "ambiguous", target: cleaned, candidates: baseHits };
246
+ }
247
+
248
+ return { kind: "missing", target: cleaned };
249
+ }
250
+ ```
251
+
252
+ - [x] **Step 5: Run tests to verify they pass**
253
+
254
+ Run: `pnpm vitest run test/knowledge-links.test.ts 2>&1 | tail -15`
255
+ Expected: all existing + new tests PASS
256
+
257
+ - [x] **Step 6: Commit**
258
+
259
+ ```bash
260
+ git add extensions/llm-wiki/lib/knowledge-links.ts test/knowledge-links.test.ts
261
+ git commit -m "feat(wiki): add WikilinkIndex + resolveWikilink normalization (#172)"
262
+ ```
263
+
264
+ ---
265
+
266
+ ### Task 3: Update buildResolvedBacklinks to use the index
267
+
268
+ **Files:**
269
+ - Modify: `extensions/llm-wiki/lib/knowledge-links.ts:247-310` (rewrite body of `buildResolvedBacklinks`)
270
+
271
+ - [x] **Step 1: Write a failing test for the updated signature**
272
+
273
+ Open `test/knowledge-links.test.ts`. The existing tests pass `known` (a `Set<string>`) as the third argument to `buildResolvedBacklinks`. They should now fail because the signature changed to accept `WikilinkIndex`.
274
+
275
+ Run: `pnpm vitest run test/knowledge-links.test.ts 2>&1 | tail -15`
276
+ Expected: existing tests FAIL (TypeScript: `Set<string>` not assignable to `WikilinkIndex`)
277
+
278
+ - [x] **Step 2: Update all existing call sites to pass an index**
279
+
280
+ In `test/knowledge-links.test.ts`, replace the `known` constant (line 8-15) with:
281
+
282
+ ```ts
283
+ const knownIds = [
284
+ "concepts/source",
285
+ "concepts/inline",
286
+ "concepts/full",
287
+ "concepts/collapsed",
288
+ "concepts/shortcut",
289
+ "concepts/encoded name",
290
+ "shared/root",
291
+ ];
292
+ const index = buildWikilinkIndex(knownIds);
293
+ ```
294
+
295
+ Then update every call to `buildResolvedBacklinks` in the existing tests to pass `index` instead of `known`:
296
+
297
+ - Line 52: `buildResolvedBacklinks("concepts/source", body, index)`
298
+ - Line 66-69: `buildResolvedBacklinks("concepts/source", body, index)`
299
+ - Line 80-82: `buildResolvedBacklinks("concepts/source", "[bad](bad%ZZ.md)", index)`
300
+ - Line 88: `buildResolvedBacklinks("concepts/source", body, index)`
301
+ - Line 97: `buildResolvedBacklinks("concepts/source", body, index)`
302
+
303
+ - [x] **Step 3: Run tests to verify they still pass against the old behavior**
304
+
305
+ Run: `pnpm vitest run test/knowledge-links.test.ts 2>&1 | tail -15`
306
+ Expected: all tests PASS (index built from the same IDs as the old Set, behavior unchanged for exact matches)
307
+
308
+ - [x] **Step 4: Rewrite the `buildResolvedBacklinks` function body**
309
+
310
+ Replace the entire `buildResolvedBacklinks` function (starts at line 247 in the original) with:
311
+
312
+ ```ts
313
+ export function buildResolvedBacklinks(
314
+ sourceId: string,
315
+ body: string,
316
+ index: WikilinkIndex,
317
+ ): ResolvedBacklinks {
318
+ const diagnostics: KnowledgeDiagnostic[] = [];
319
+ const unresolved: UnresolvedKnowledgeLink[] = [];
320
+ const targets = new Set<string>();
321
+ const allLinks = extractKnowledgeLinks(body);
322
+
323
+ // Process Markdown links (exact-match only — no normalization)
324
+ for (const link of allLinks.markdown) {
325
+ const resolved = resolveMarkdownTarget(link.target, sourceId);
326
+ if (resolved.kind === "escape") {
327
+ diagnostics.push(
328
+ diag(
329
+ "warning",
330
+ "link_path_escape",
331
+ `${sourceId}.md`,
332
+ `Link escapes bundle root: ${link.target}`,
333
+ ),
334
+ );
335
+ } else if (resolved.kind === "invalid") {
336
+ diagnostics.push(
337
+ diag(
338
+ "warning",
339
+ "link_unresolved",
340
+ `${sourceId}.md`,
341
+ `Malformed percent-encoded link: ${link.target}`,
342
+ ),
343
+ );
344
+ } else if (resolved.kind === "concept") {
345
+ const canonical = index.byExact.get(resolved.id.normalize("NFC"));
346
+ if (canonical) {
347
+ targets.add(canonical);
348
+ } else {
349
+ unresolved.push({ target: resolved.id, syntax: "markdown" });
350
+ diagnostics.push(
351
+ diag(
352
+ "warning",
353
+ "link_unresolved",
354
+ `${sourceId}.md`,
355
+ `Unresolved link: ${resolved.id}`,
356
+ ),
357
+ );
358
+ }
359
+ }
360
+ // external and empty are silently ignored
361
+ }
362
+
363
+ // Process wikilinks (lenient: exact → normalized path → bare title)
364
+ for (const link of allLinks.wikilinks) {
365
+ const res = resolveWikilink(link.target, index);
366
+ if (res.kind === "resolved") {
367
+ targets.add(res.id);
368
+ } else if (res.kind === "ambiguous") {
369
+ diagnostics.push(
370
+ diag(
371
+ "warning",
372
+ "link_ambiguous",
373
+ `${sourceId}.md`,
374
+ `Ambiguous wikilink: ${res.target} (candidates: ${res.candidates.join(", ")})`,
375
+ ),
376
+ );
377
+ } else {
378
+ unresolved.push({ target: res.target, syntax: "wikilink" });
379
+ diagnostics.push(
380
+ diag(
381
+ "warning",
382
+ "link_unresolved",
383
+ `${sourceId}.md`,
384
+ `Unresolved wikilink: ${res.target}`,
385
+ ),
386
+ );
387
+ }
388
+ }
389
+
390
+ // Sort and deduplicate
391
+ const sorted = [...targets].sort(compareCodePoint);
392
+
393
+ return { targets: sorted, unresolved, diagnostics };
394
+ }
395
+ ```
396
+
397
+ - [x] **Step 5: Run tests — verify all pass**
398
+
399
+ Run: `pnpm vitest run test/knowledge-links.test.ts 2>&1 | tail -15`
400
+ Expected: all PASS (exact-match fast path covers all existing test cases; normalization tests added in Task 2 also pass)
401
+
402
+ - [x] **Step 6: Run full test suite to catch any breakage elsewhere**
403
+
404
+ Run: `pnpm test 2>&1 | tail -20`
405
+ Expected: all PASS
406
+
407
+ - [x] **Step 7: Commit**
408
+
409
+ ```bash
410
+ git add extensions/llm-wiki/lib/knowledge-links.ts test/knowledge-links.test.ts
411
+ git commit -m "feat(wiki): use WikilinkIndex in buildResolvedBacklinks (#172)"
412
+ ```
413
+
414
+ ---
415
+
416
+ ### Task 4: Update metadata.ts caller
417
+
418
+ **Files:**
419
+ - Modify: `extensions/llm-wiki/lib/metadata.ts:14` (import)
420
+ - Modify: `extensions/llm-wiki/lib/metadata.ts:89` (build index)
421
+ - Modify: `extensions/llm-wiki/lib/metadata.ts:248-271` (buildBacklinks signature)
422
+
423
+ - [x] **Step 1: Update the import line**
424
+
425
+ Change line 14 from:
426
+
427
+ ```ts
428
+ import { buildResolvedBacklinks } from "./knowledge-links.js";
429
+ ```
430
+
431
+ to:
432
+
433
+ ```ts
434
+ import { buildResolvedBacklinks, buildWikilinkIndex } from "./knowledge-links.js";
435
+ ```
436
+
437
+ - [x] **Step 2: Build the index once at line 89**
438
+
439
+ Replace:
440
+
441
+ ```ts
442
+ const knownIds = new Set(documents.map((d) => d.id));
443
+ const backlinks = buildBacklinks(documents, knownIds, allDiagnostics);
444
+ ```
445
+
446
+ with:
447
+
448
+ ```ts
449
+ const wikilinkIndex = buildWikilinkIndex(documents.map((d) => d.id));
450
+ const backlinks = buildBacklinks(documents, wikilinkIndex, allDiagnostics);
451
+ ```
452
+
453
+ - [x] **Step 3: Update the `buildBacklinks` function signature**
454
+
455
+ Replace lines 248-271 (the `buildBacklinks` function body) with:
456
+
457
+ ```ts
458
+ /** Build backlinks from discovered documents using shared link resolution. */
459
+ function buildBacklinks(
460
+ documents: KnowledgeDocument[],
461
+ index: import("./knowledge-links.js").WikilinkIndex,
462
+ diagnostics: KnowledgeDiagnostic[],
463
+ ): Backlinks {
464
+ const inbound: Backlinks = {};
465
+
466
+ // Initialize parsed concept IDs with empty arrays
467
+ for (const doc of documents) {
468
+ inbound[doc.id] = [];
469
+ }
470
+
471
+ // Resolve links for each document
472
+ for (const doc of documents) {
473
+ const result = buildResolvedBacklinks(doc.id, doc.body, index);
474
+ diagnostics.push(...result.diagnostics);
475
+ for (const target of result.targets) {
476
+ if (inbound[target] && !inbound[target].includes(doc.id)) {
477
+ inbound[target].push(doc.id);
478
+ }
479
+ }
480
+ }
481
+
482
+ // Sort targets for determinism
483
+ for (const [id, targets] of Object.entries(inbound)) {
484
+ inbound[id] = [...targets].sort(compareCodePoint);
485
+ }
486
+
487
+ return inbound;
488
+ }
489
+ ```
490
+
491
+ - [x] **Step 4: Run typecheck**
492
+
493
+ Run: `pnpm typecheck`
494
+ Expected: PASS
495
+
496
+ - [x] **Step 5: Run tests**
497
+
498
+ Run: `pnpm test 2>&1 | tail -20`
499
+ Expected: all PASS
500
+
501
+ - [x] **Step 6: Commit**
502
+
503
+ ```bash
504
+ git add extensions/llm-wiki/lib/metadata.ts
505
+ git commit -m "feat(wiki): use WikilinkIndex in metadata rebuild (#172)"
506
+ ```
507
+
508
+ ---
509
+
510
+ ### Task 5: Update tools.ts wiki_lint caller
511
+
512
+ **Files:**
513
+ - Modify: `extensions/llm-wiki/lib/tools.ts:14` (import)
514
+ - Modify: `extensions/llm-wiki/lib/tools.ts:874-945` (wiki_lint implementation)
515
+
516
+ - [x] **Step 1: Update the import**
517
+
518
+ Change line 14 from:
519
+
520
+ ```ts
521
+ import { buildResolvedBacklinks } from "./knowledge-links.js";
522
+ ```
523
+
524
+ to:
525
+
526
+ ```ts
527
+ import { buildResolvedBacklinks, buildWikilinkIndex } from "./knowledge-links.js";
528
+ ```
529
+
530
+ - [x] **Step 2: Build index once and count ambiguous links**
531
+
532
+ In the `wiki_lint` function, replace lines 874-878:
533
+
534
+ ```ts
535
+ const discovery = discoverKnowledgeDocuments(paths);
536
+ const pages = discovery.documents;
537
+ const knownIds = new Set(pages.map((page) => page.id));
538
+ const inbound = Object.fromEntries(pages.map((page) => [page.id, 0]));
539
+ ```
540
+
541
+ with:
542
+
543
+ ```ts
544
+ const discovery = discoverKnowledgeDocuments(paths);
545
+ const pages = discovery.documents;
546
+ const wikilinkIndex = buildWikilinkIndex(pages.map((page) => page.id));
547
+ const inbound = Object.fromEntries(pages.map((page) => [page.id, 0]));
548
+ ```
549
+
550
+ Then replace line 882:
551
+
552
+ ```ts
553
+ const resolved = buildResolvedBacklinks(page.id, page.body, knownIds);
554
+ ```
555
+
556
+ with:
557
+
558
+ ```ts
559
+ const resolved = buildResolvedBacklinks(page.id, page.body, wikilinkIndex);
560
+ ```
561
+
562
+ - [x] **Step 3: Count ambiguous links in the lint loop**
563
+
564
+ After the existing `for (const page of pages)` loop (which counts `missingPages`), add an ambiguous counter. At line 884 (after the `for (const unresolved of resolved.unresolved)` block), add:
565
+
566
+ ```ts
567
+ for (const d of resolved.diagnostics) {
568
+ if (d.code === "link_ambiguous") {
569
+ findings.push(d.message.replace(`Ambiguous wikilink: `, `Ambiguous: `));
570
+ }
571
+ }
572
+ ```
573
+
574
+ - [x] **Step 4: Add ambiguous count to the report summary**
575
+
576
+ In the `reportLines` array (around line 935), add a new line after `- Missing pages: ${missingPages}`:
577
+
578
+ ```ts
579
+ `- Missing pages: ${missingPages}`,
580
+ ```
581
+
582
+ Note: ambiguous links are surfaced as findings but not counted separately in the summary — the lint report keeps its existing summary format (total/orphans/missing/contradictions). The findings section already lists each ambiguous link with its candidates.
583
+
584
+ - [x] **Step 5: Run typecheck + tests**
585
+
586
+ Run: `pnpm typecheck && pnpm test 2>&1 | tail -20`
587
+ Expected: all PASS
588
+
589
+ - [x] **Step 6: Commit**
590
+
591
+ ```bash
592
+ git add extensions/llm-wiki/lib/tools.ts
593
+ git commit -m "feat(wiki): use WikilinkIndex in wiki_lint, report ambiguous links (#172)"
594
+ ```
595
+
596
+ ---
597
+
598
+ ### Task 6: Add bare-title-vs-exact-resolution order test
599
+
600
+ **Files:**
601
+ - Modify: `test/knowledge-links.test.ts`
602
+
603
+ - [x] **Step 1: Add a test confirming exact match is preferred over normalized**
604
+
605
+ Add to the `describe("resolveWikilink normalization")` block (this overlaps with the "prefers exact match" test in Task 2 — verify it's present; if not, add it):
606
+
607
+ ```ts
608
+ it("exact match is returned even when normalization would find a different page", () => {
609
+ // Page exists at exactly `concepts/ibm` AND at `entities/ibm`
610
+ // The link `[[concepts/ibm]]` should resolve to `concepts/ibm` (exact)
611
+ // without ambiguity — normalization is only consulted when exact fails.
612
+ const index = buildWikilinkIndex(["concepts/ibm", "entities/ibm"]);
613
+ const result = buildResolvedBacklinks("sources/src-1", "[[concepts/ibm]]", index);
614
+ expect(result.targets).toEqual(["concepts/ibm"]);
615
+ expect(result.diagnostics).toEqual([]);
616
+ });
617
+
618
+ it("existing slash-less link with whitespace matches by slug path", () => {
619
+ const index = buildWikilinkIndex(["entities/zosma-harness"]);
620
+ const result = buildResolvedBacklinks(
621
+ "sources/src-1",
622
+ "[[entities/zosma harness]]",
623
+ index,
624
+ );
625
+ expect(result.targets).toEqual(["entities/zosma-harness"]);
626
+ expect(result.unresolved).toEqual([]);
627
+ });
628
+
629
+ it("ambiguous bare title does not resolve to any target", () => {
630
+ const index = buildWikilinkIndex(["entities/ibm", "concepts/ibm", "notes/ibm"]);
631
+ const result = buildResolvedBacklinks("sources/src-1", "[[ibm]]", index);
632
+ expect(result.targets).toEqual([]);
633
+ expect(result.unresolved).toEqual([]);
634
+ expect(result.diagnostics[0].code).toBe("link_ambiguous");
635
+ expect(result.diagnostics[0].message).toContain("entities/ibm");
636
+ expect(result.diagnostics[0].message).toContain("concepts/ibm");
637
+ expect(result.diagnostics[0].message).toContain("notes/ibm");
638
+ });
639
+
640
+ it("bare title that matches zero pages is reported as unresolved", () => {
641
+ const index = buildWikilinkIndex(["entities/ibm"]);
642
+ const result = buildResolvedBacklinks("sources/src-1", "[[nonexistent]]", index);
643
+ expect(result.targets).toEqual([]);
644
+ expect(result.unresolved).toEqual([{ target: "nonexistent", syntax: "wikilink" }]);
645
+ expect(result.diagnostics[0].code).toBe("link_unresolved");
646
+ });
647
+ ```
648
+
649
+ - [x] **Step 2: Run tests**
650
+
651
+ Run: `pnpm vitest run test/knowledge-links.test.ts 2>&1 | tail -15`
652
+ Expected: all PASS
653
+
654
+ - [x] **Step 3: Run full test suite**
655
+
656
+ Run: `pnpm test 2>&1 | tail -20`
657
+ Expected: all PASS
658
+
659
+ - [x] **Step 4: Commit**
660
+
661
+ ```bash
662
+ git add test/knowledge-links.test.ts
663
+ git commit -m "test(wiki): add resolution-order and bare-title edge case tests (#172)"
664
+ ```
665
+
666
+ ---
667
+
668
+ ### Task 7: Lint and verify end-to-end
669
+
670
+ **Files:** None (verification only)
671
+
672
+ - [x] **Step 1: Run lint**
673
+
674
+ Run: `pnpm lint`
675
+ Expected: PASS (Biome clean)
676
+
677
+ - [x] **Step 2: Run full test suite one final time**
678
+
679
+ Run: `pnpm test 2>&1 | tail -25`
680
+ Expected: all PASS
681
+
682
+ - [x] **Step 3: Verify real vault impact — measure before/after**
683
+
684
+ Run a quick post-implementation scan against the real vault. This is the same scan used in the audit, to confirm the fix works on live data:
685
+
686
+ ```bash
687
+ node -e "
688
+ const { readdirSync, readFileSync, statSync } = require('fs');
689
+ const { join } = require('path');
690
+ const wiki = join(process.env.HOME, '.llm-wiki/wiki');
691
+ const FOLDERS = ['entities','concepts','sources','synthesis','analyses','comparisons','decisions','incidents','references','projects','notes','journal','questions'];
692
+ const ids = new Set();
693
+ function walk(d) { for (const e of readdirSync(d)) { const p=join(d,e); statSync(p).isDirectory()?walk(p):e.endsWith('.md')&&ids.add(p.slice(wiki.length+1,-3)); }}
694
+ for (const d of FOLDERS) try{walk(join(wiki,d))}catch{}
695
+ let total=0,missing=0,bare=0;
696
+ function scan(d) { for(const e of readdirSync(d)){const p=join(d,e);const s=statSync(p);if(s.isDirectory()){scan(p);continue}if(!e.endsWith('.md'))continue;const body=readFileSync(p,'utf8');for(const m of body.matchAll(/\[\[([^\]\|]+)(?:\|[^\]]*)?\]\]/g)){total++;const t=m[1].trim().replace(/\\\\$/,'');if(ids.has(t)||[...ids].some(i=>i.toLowerCase()===t.toLowerCase()))continue;if(!t.includes('/'))bare++;missing++;}}}
697
+ for (const d of FOLDERS) try{scan(join(wiki,d))}catch{}
698
+ console.log('Before fix: ~271 unresolved (167 bare + 104 missing)');
699
+ console.log('After fix expected: ~104 (only truly missing foldered pages remain; bare titles resolved)');
700
+ "
701
+ ```
702
+
703
+ Note: this scan is run after the code change — the agent writing this must confirm the expected reduction. The exact count depends on which of the 167 bare-title links uniquely resolve (the index for the real vault has 1 entity/concept per bare slug). In a real execution, this scan runs against the live vault.
704
+
705
+ - [x] **Step 4: Run wiki_lint to see the updated report**
706
+
707
+ In an active pi session with this code deployed, run the `wiki_lint` tool. The report should show:
708
+ - `- Missing pages: ~104` (down from 217+)
709
+ - New "Ambiguous" findings in the Findings section (if any bare titles map to multiple pages)
710
+
711
+ - [x] **Step 5: Final commit (if any lint fixes were needed)**
712
+
713
+ ```bash
714
+ git add -A
715
+ git commit -m "chore: lint and post-implementation verification (#172)"
716
+ ```
717
+
718
+ ---
719
+
720
+ ### Summary of behavior changes
721
+
722
+ | Before | After |
723
+ |---|---|
724
+ | `[[zosma harness]]` → "Unresolved wikilink" | → resolves to `entities/zosma-harness` (unique basename slug) |
725
+ | `[[concepts/Attention Is All You Need]]` → "Unresolved" | → resolves to `concepts/attention-is-all-you-need` |
726
+ | `[[ibm]]` when entities + concepts both exist → "Unresolved" | → `link_ambiguous` diagnostic with candidate list |
727
+ | `[[entities/ibm]]` when entities + concepts both exist → resolved | → resolved (exact match, no normalization needed) |
728
+ | Markdown links `[foo](foo.md)` → unchanged | → unchanged (markdown path uses exact match only) |
729
+ | Lint: 217+ missing pages | → ~104 (only truly missing pages remain) |
730
+
731
+ ### What this does NOT do (deferred)
732
+
733
+ - **Pre-write validation tool** (the #172 feature with `off|warn|strict|normalize` config) — the external reporter's PR scope. With this fix in, "normalize" mode is the default behavior, so his implementation can focus purely on the write-time warn hook.
734
+ - **Ambiguity resolution strategy** (auto-pick first, or prompt) — ambiguity is surfaced as a warning, letting the agent or human disambiguate.
735
+ - **Cross-vault link resolution** — no change; cross-vault links are genuinely external targets.