@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,695 @@
1
+ # Wikilink Pre-Write Validation & Normalization (Layer 2, issue #172) 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:** Gate the deterministic ingest write path (`commitSynthesis`) so wikilinks in an ingested source body are validated/normalized before pages are written, per a new `wikilinkValidation` setting (default `warn`).
6
+
7
+ **Architecture:** A pure helper `auditWikilinks(body, index, sourceId, mode)` reuses the existing `extractKnowledgeLinks` + `resolveWikilink` from Layer 1. It returns diagnostics (`link_unresolved`/`link_ambiguous`) and, in `normalize` mode, a rewritten body where resolvable targets are replaced by their canonical page id. `commitSynthesis` — the single choke point for background ingest writes — builds an index from the existing registry **plus the pages this commit creates** (so same-batch links don't false-positive), runs the gate on the source body, and per mode: `off` (no-op), `warn` (collect diagnostics, write proceeds), `strict` (block the write, return `ok:false`), `normalize` (rewrite the body, then write). The mode is threaded from `runtime.config` through `runIngestSynthesis` into `commitSynthesis`, exactly mirroring the existing `synthesisLanguage` plumbing.
8
+
9
+ **Tech Stack:** TypeScript (ESM, ES2022), Vitest, `node:fs`, existing `knowledge-links.ts` resolver (Layer 1).
10
+
11
+ **Roadmap:** None
12
+
13
+ **Phase:** Single-plan implementation
14
+
15
+ ---
16
+
17
+ ## Scope (decided with user)
18
+
19
+ - **Default mode: `warn`.** Ingest always writes; broken links are reported, not blocked, unless the user opts into `strict`.
20
+ - **Write path: ingest only.** `commitSynthesis` is the deterministic background-ingest writer (the default `wiki_ingest` path). The other write paths (retro/observe/lint-stub, and the `background:false` manual path where the main agent writes directly) are **out of scope** — follow-up.
21
+ - **Body gated: the ingested SOURCE body only.** Entity/concept pages are generated one-line templates whose only wikilink is the self-reference `[[sources/<id>]]` (always resolves). The model-authored links all live in the source body (`summary`, `key_takeaways`, `quotes`). Auditing only the source body is the lazy-correct scope; description-field auditing is a follow-up if needed.
22
+ - **Normalization is alias-safe:** it rewrites only the link *target* token (via the same regex the parser uses), preserving any `|alias`, so no structural link is ever corrupted.
23
+
24
+ ## File Structure
25
+
26
+ - **Modify** `extensions/llm-wiki/lib/task-config.ts` — add `WikilinkValidationMode` usage to the `TaskConfig` interface, a `resolveWikilinkValidation()` resolver, and the `KNOWN_KEYS` entry.
27
+ - **Modify** `extensions/llm-wiki/lib/knowledge-links.ts` — export `auditWikilinks()` + `WikilinkAuditResult` (the `WikilinkValidationMode` type was already added in Task 1).
28
+ - **Modify** `extensions/llm-wiki/lib/ingest-worker.ts` — add `wikilinkValidation?` param to `commitSynthesis`, `wikilinkDiagnostics?` to `CommitResult`, and `wikilinkValidation?` to `RunIngestSynthesisArgs`; wire the gate into `commitSynthesis` and thread the mode through `runIngestSynthesis`.
29
+ - **Modify** `extensions/llm-wiki/lib/tools.ts` — pass `runtime.config.wikilinkValidation` into `runIngestSynthesis` and surface diagnostic counts in the ingest report line.
30
+ - **Test** `test/knowledge-links.test.ts` (append — `auditWikilinks` unit tests).
31
+ - **Test** `test/ingest-worker.test.ts` (append — `commitSynthesis` gate integration tests).
32
+ - **Test** `test/task-config.test.ts` (create — `resolveWikilinkValidation` unit tests).
33
+
34
+ ---
35
+
36
+ ### Task 1: Config type + resolver
37
+
38
+ **Files:**
39
+ - Modify: `extensions/llm-wiki/lib/knowledge-links.ts` (add the `WikilinkValidationMode` type only)
40
+ - Modify: `extensions/llm-wiki/lib/task-config.ts`
41
+ - Test: `test/task-config.test.ts` (create)
42
+
43
+ - [ ] **Step 1: Write the failing test**
44
+
45
+ Create `test/task-config.test.ts`:
46
+
47
+ ```ts
48
+ import { mkdirSync, rmSync, writeFileSync, mkdtempSync } from "node:fs";
49
+ import { join } from "node:path";
50
+ import { tmpdir } from "node:os";
51
+ import { describe, expect, it } from "vitest";
52
+ import {
53
+ loadTaskConfig,
54
+ resolveWikilinkValidation,
55
+ type TaskConfig,
56
+ } from "../extensions/llm-wiki/lib/task-config.js";
57
+
58
+ describe("resolveWikilinkValidation", () => {
59
+ it("defaults to warn when unset/undefined", () => {
60
+ expect(resolveWikilinkValidation(undefined)).toBe("warn");
61
+ expect(resolveWikilinkValidation({})).toBe("warn");
62
+ });
63
+
64
+ it("returns an explicit valid mode", () => {
65
+ for (const m of ["off", "warn", "strict", "normalize"] as const) {
66
+ const config: TaskConfig = { wikilinkValidation: m };
67
+ expect(resolveWikilinkValidation(config)).toBe(m);
68
+ }
69
+ });
70
+
71
+ it("falls back to warn on an invalid value", () => {
72
+ const config = { wikilinkValidation: "bogus" } as unknown as TaskConfig;
73
+ expect(resolveWikilinkValidation(config)).toBe("warn");
74
+ });
75
+
76
+ it("reads wikilinkValidation from the llm-wiki settings namespace", () => {
77
+ const project = mkdtempSync(join(tmpdir(), "wl-"));
78
+ try {
79
+ mkdirSync(join(project, ".omp"), { recursive: true });
80
+ writeFileSync(
81
+ join(project, ".omp", "settings.json"),
82
+ JSON.stringify({ "llm-wiki": { wikilinkValidation: "strict" } }),
83
+ );
84
+ // The settings value must flow through readNamespacedConfig into TaskConfig.
85
+ expect(resolveWikilinkValidation(loadTaskConfig(project))).toBe("strict");
86
+ } finally {
87
+ rmSync(project, { recursive: true, force: true });
88
+ }
89
+ });
90
+ });
91
+ ```
92
+
93
+ > The `.omp/settings.json` write pattern mirrors `test/ambient-gate.test.ts` (the host the test suite detects). If the test env detects a different host, write to that host's settings path instead — the assertion on `loadTaskConfig(project)` is what matters.
94
+
95
+ - [ ] **Step 2: Run test to verify it fails**
96
+
97
+ Run: `pnpm vitest run test/task-config.test.ts`
98
+ Expected: FAIL — `resolveWikilinkValidation` is not exported from `task-config.js`.
99
+
100
+ - [ ] **Step 3: Write minimal implementation**
101
+
102
+ **Prep — the mode type lives in `knowledge-links.ts`** (owned here so `task-config.ts` can import it before Task 2's helper exists). Append to `extensions/llm-wiki/lib/knowledge-links.ts`:
103
+
104
+ ```ts
105
+ export type WikilinkValidationMode = "off" | "warn" | "strict" | "normalize";
106
+ ```
107
+
108
+ Then in `extensions/llm-wiki/lib/task-config.ts`:
109
+
110
+ (a) Add the import at the top (with the other `./` imports):
111
+
112
+ ```ts
113
+ import type { WikilinkValidationMode } from "./knowledge-links.js";
114
+ ```
115
+
116
+ (b) Add this field to the `TaskConfig` interface (place it right after the `synthesisMaxTokens?: number;` field, before the closing `}`):
117
+
118
+ ```ts
119
+ /**
120
+ * Wikilink gate applied to the ingested source body before pages are
121
+ * written (issue #172, Layer 2). Reuses the Layer 1 resolver.
122
+ * - "off" : no-op.
123
+ * - "warn" : write proceeds; unresolved/ambiguous links are reported.
124
+ * - "strict" : block the write (commit returns ok:false) if any link is unresolvable.
125
+ * - "normalize" : rewrite resolvable links to their canonical id, then write.
126
+ * Default "warn". See `resolveWikilinkValidation`.
127
+ */
128
+ wikilinkValidation?: WikilinkValidationMode;
129
+ ```
130
+
131
+ (c) Add the resolver, next to `noticesEnabled` (after the `noticesEnabled` function):
132
+
133
+ ```ts
134
+ const WIKILINK_VALIDATION_MODES: readonly WikilinkValidationMode[] = [
135
+ "off",
136
+ "warn",
137
+ "strict",
138
+ "normalize",
139
+ ];
140
+
141
+ /**
142
+ * Resolve the wikilink write-gate mode (issue #172, Layer 2). Defaults to
143
+ * `warn` — ingest always writes and reports; only an explicit `strict` blocks.
144
+ * Unknown values fall back to `warn` rather than failing the ingest.
145
+ */
146
+ export function resolveWikilinkValidation(
147
+ config: TaskConfig | undefined,
148
+ ): WikilinkValidationMode {
149
+ const v = config?.wikilinkValidation;
150
+ if (v && (WIKILINK_VALIDATION_MODES as readonly string[]).includes(v)) return v;
151
+ return "warn";
152
+ }
153
+ ```
154
+
155
+ (d) Add `"wikilinkValidation"` to the `KNOWN_KEYS` array (after `"synthesisMaxTokens"`):
156
+
157
+ ```ts
158
+ "wikilinkValidation",
159
+ ```
160
+
161
+ (e) Add a parse branch to `readNamespacedConfig` so the value actually reaches `TaskConfig`. This function copies settings keys **explicitly, one branch per key** — the `TaskConfig` field and `KNOWN_KEYS` entry alone do NOT make a settings value flow through. Insert this after the `synthesisMaxTokens` block (after `out.synthesisMaxTokens = Math.floor(maxTokens);`):
162
+
163
+ ```ts
164
+ const wl = section.wikilinkValidation;
165
+ if (
166
+ typeof wl === "string" &&
167
+ (WIKILINK_VALIDATION_MODES as readonly string[]).includes(wl)
168
+ ) {
169
+ out.wikilinkValidation = wl as WikilinkValidationMode;
170
+ }
171
+ ```
172
+
173
+ > `wl` narrows to `string` (not the union) via `typeof wl === "string"`; the `.includes` guard already proves it is a valid mode, so the `as WikilinkValidationMode` cast is safe and required for `tsc` (Vitest does not typecheck — this would only fail at `pnpm typecheck` in Task 5 if omitted).
174
+
175
+ > `WIKILINK_VALIDATION_MODES` is the module-level const defined in step (c) above; the `readNamespacedConfig` body runs at call time, after module evaluation, so declaration order does not matter.
176
+
177
+ - [ ] **Step 4: Run test to verify it passes**
178
+
179
+ Run: `pnpm vitest run test/task-config.test.ts`
180
+ Expected: PASS (4 tests).
181
+
182
+ - [ ] **Step 5: Commit**
183
+
184
+ ```bash
185
+ git add extensions/llm-wiki/lib/task-config.ts test/task-config.test.ts
186
+ git commit -m "feat(wikilink): add wikilinkValidation setting + resolver (default warn)"
187
+ ```
188
+
189
+ ---
190
+
191
+ ### Task 2: `auditWikilinks` helper
192
+
193
+ **Files:**
194
+ - Modify: `extensions/llm-wiki/lib/knowledge-links.ts`
195
+ - Test: `test/knowledge-links.test.ts` (append)
196
+
197
+ **Context (already in this file):** `buildWikilinkIndex(ids: Iterable<string>)`, `resolveWikilink(target, index): WikilinkResolution` where `WikilinkResolution` is the union `{ kind: "resolved"; id: string } | { kind: "ambiguous"; target: string; candidates: string[] } | { kind: "missing"; target: string }` (note: **`kind`/`id`**, not `status`/`canonicalId`), `extractKnowledgeLinks(body).wikilinks` (array of `{target, offset}`), `normalizeWikilinkTarget(raw)`, the imported `KnowledgeDiagnostic` type, and `WikilinkValidationMode` (added in Task 1). The parser regex is `/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g`.
198
+
199
+ - [ ] **Step 1: Write the failing test**
200
+
201
+ Two edits to `test/knowledge-links.test.ts`:
202
+
203
+ 1. Add `auditWikilinks` to the **existing** top-of-file import (the one that already imports `buildResolvedBacklinks`, `buildWikilinkIndex`, `extractKnowledgeLinks`, `extractLegacyWikilinks`). Do NOT add a second import statement from this module.
204
+ 2. Append this `describe` block to the **end** of the file. `buildWikilinkIndex` is already imported at the top, so it is used directly (no alias, no new import):
205
+
206
+ ```ts
207
+ const idx = buildWikilinkIndex([
208
+ "entities/alice",
209
+ "concepts/transformer",
210
+ "concepts/attention",
211
+ "concepts/other-page",
212
+ ]);
213
+
214
+ describe("auditWikilinks", () => {
215
+ it("off returns no diagnostics and unchanged body", () => {
216
+ const r = auditWikilinks("see [[ghost]]", idx, "SRC-001", "off");
217
+ expect(r.diagnostics).toEqual([]);
218
+ expect(r.body).toBe("see [[ghost]]");
219
+ expect(r.changed).toBe(false);
220
+ });
221
+
222
+ it("warn reports an unresolved link and leaves the body untouched", () => {
223
+ const r = auditWikilinks("bad [[ghost]]", idx, "SRC-001", "warn");
224
+ expect(r.body).toBe("bad [[ghost]]");
225
+ expect(r.diagnostics.map((d) => d.code)).toContain("link_unresolved");
226
+ });
227
+
228
+ it("warn flags ambiguous when a bare target matches multiple pages", () => {
229
+ const ambiguous = buildWikilinkIndex(["entities/alice", "concepts/alice"]);
230
+ const r = auditWikilinks("who is [[alice]]?", ambiguous, "SRC-001", "warn");
231
+ const amb = r.diagnostics.find((d) => d.code === "link_ambiguous");
232
+ expect(amb).toBeDefined();
233
+ expect(r.body).toBe("who is [[alice]]?");
234
+ });
235
+
236
+ it("normalize rewrites resolvable targets to canonical id, preserves alias", () => {
237
+ const body = "see [[transformer|TF]] and [[alice]]";
238
+ const r = auditWikilinks(body, idx, "SRC-001", "normalize");
239
+ expect(r.body).toBe("see [[concepts/transformer|TF]] and [[entities/alice]]");
240
+ expect(r.changed).toBe(true);
241
+ });
242
+
243
+ it("normalize leaves unresolvable links verbatim", () => {
244
+ const r = auditWikilinks("see [[ghost]]", idx, "SRC-001", "normalize");
245
+ expect(r.body).toBe("see [[ghost]]");
246
+ expect(r.changed).toBe(false);
247
+ });
248
+
249
+ it("normalize is a no-op when every link is already canonical", () => {
250
+ const r = auditWikilinks("see [[concepts/attention]]", idx, "SRC-001", "normalize");
251
+ expect(r.body).toBe("see [[concepts/attention]]");
252
+ expect(r.changed).toBe(false);
253
+ });
254
+ });
255
+ ```
256
+
257
+ - [ ] **Step 2: Run test to verify it fails**
258
+
259
+ Run: `pnpm vitest run test/knowledge-links.test.ts -t auditWikilinks`
260
+ Expected: FAIL — `auditWikilinks` is not exported.
261
+
262
+ - [ ] **Step 3: Write minimal implementation**
263
+
264
+ Append to the end of `extensions/llm-wiki/lib/knowledge-links.ts` (the `WikilinkValidationMode` type was already added in Task 1 — do NOT redefine it):
265
+
266
+ ```ts
267
+ // ── Pre-write validation & normalization (issue #172, Layer 2) ─────────
268
+
269
+ export interface WikilinkAuditResult {
270
+ /** Unresolved / ambiguous link diagnostics (empty for "off" / clean bodies). */
271
+ diagnostics: KnowledgeDiagnostic[];
272
+ /** The body after normalization (identical to input unless normalize rewrote links). */
273
+ body: string;
274
+ /** True only when normalize changed the body. */
275
+ changed: boolean;
276
+ }
277
+
278
+ const WIKILINK_REPLACE_RE = /\[\[([^\]|]+)(\|[^\]]*)?\]\]/g;
279
+
280
+ /**
281
+ * Audit a markdown body's wikilinks against the page index.
282
+ *
283
+ * - Collects `link_unresolved` / `link_ambiguous` diagnostics for every link
284
+ * that does not resolve to exactly one page (skipped for "off").
285
+ * - In "normalize" mode, additionally rewrites each link that DOES resolve to
286
+ * its canonical page id. Only the target token is replaced (the parser's own
287
+ * regex is reused), so `|alias`, escaping, and surrounding text are preserved.
288
+ *
289
+ * Pure: no I/O. The caller supplies the index (typically `buildWikilinkIndex`
290
+ * over existing page ids plus the ids created by the same commit).
291
+ */
292
+ export function auditWikilinks(
293
+ body: string,
294
+ index: WikilinkIndex,
295
+ sourceId: string,
296
+ mode: WikilinkValidationMode,
297
+ ): WikilinkAuditResult {
298
+ if (mode === "off") return { diagnostics: [], body, changed: false };
299
+
300
+ const diagnostics: KnowledgeDiagnostic[] = [];
301
+ for (const { target } of extractKnowledgeLinks(body).wikilinks) {
302
+ const resolved = resolveWikilink(target, index);
303
+ if (resolved.kind === "ambiguous") {
304
+ diagnostics.push({
305
+ severity: "warning",
306
+ code: "link_ambiguous",
307
+ path: sourceId,
308
+ message: `Wikilink target "${target}" matches multiple pages (${resolved.candidates.join(", ")}).`,
309
+ });
310
+ } else if (resolved.kind === "missing") {
311
+ diagnostics.push({
312
+ severity: "warning",
313
+ code: "link_unresolved",
314
+ path: sourceId,
315
+ message: `Wikilink target "${target}" does not match any page.`,
316
+ });
317
+ }
318
+ }
319
+
320
+ let out = body;
321
+ if (mode === "normalize") {
322
+ out = body.replace(WIKILINK_REPLACE_RE, (full, raw: string, alias: string | undefined) => {
323
+ const resolved = resolveWikilink(normalizeWikilinkTarget(raw), index);
324
+ return resolved.kind === "resolved"
325
+ ? `[[${resolved.id}${alias ?? ""}]]`
326
+ : full;
327
+ });
328
+ }
329
+
330
+ return { diagnostics, body: out, changed: out !== body };
331
+ }
332
+ ```
333
+
334
+ - [ ] **Step 4: Run test to verify it passes**
335
+
336
+ Run: `pnpm vitest run test/knowledge-links.test.ts`
337
+ Expected: PASS (existing + new `auditWikilinks` tests).
338
+
339
+ - [ ] **Step 5: Commit**
340
+
341
+ ```bash
342
+ git add extensions/llm-wiki/lib/knowledge-links.ts test/knowledge-links.test.ts
343
+ git commit -m "feat(wikilink): add auditWikilinks (warn/strict/normalize, alias-safe)"
344
+ ```
345
+
346
+ ---
347
+
348
+ ### Task 3: Wire the gate into `commitSynthesis`
349
+
350
+ **Files:**
351
+ - Modify: `extensions/llm-wiki/lib/ingest-worker.ts`
352
+ - Test: `test/ingest-worker.test.ts` (append)
353
+
354
+ **Context (already imported in this file):** `join`, `existsSync`, `readKnowledgeDocumentFile`, `writeKnowledgeDocumentFile`, `createKnowledgeDocument`, `patchKnowledgeDocument`, `buildIngestedSourcePageBody` (local), `slugify`, `VaultPaths`. The existing test file uses `getVaultPaths(wikiDir)` + `ensureVaultStructure(paths)` to build a vault and calls `commitSynthesis(paths, "SRC-001", MANIFEST, DATA, "2026-06-06")`.
355
+
356
+ - [ ] **Step 1: Write the failing test**
357
+
358
+ Append to `test/ingest-worker.test.ts`. Reuse the file's existing `MANIFEST`, and its `beforeEach`/`afterEach` vault scaffolding (`wikiDir`, `getVaultPaths`, `ensureVaultStructure`). Follow the exact variable names those hooks already declare.
359
+
360
+ ```ts
361
+ import { auditWikilinks } from "../extensions/llm-wiki/lib/knowledge-links.js";
362
+
363
+ describe("commitSynthesis wikilink gate", () => {
364
+ function makeData(summary: string): SynthesisData {
365
+ return {
366
+ summary,
367
+ key_takeaways: ["a"],
368
+ entities: [{ title: "Alice", description: "A person." }],
369
+ concepts: [{ title: "Transformer", definition: "An architecture." }],
370
+ };
371
+ }
372
+
373
+ it("strict blocks the write and returns ok:false on an unresolved link", () => {
374
+ const paths = getVaultPaths(wikiDir);
375
+ const res = commitSynthesis(
376
+ paths,
377
+ "SRC-001",
378
+ MANIFEST,
379
+ makeData("See [[ghost-page]] for details."),
380
+ "2026-06-06",
381
+ undefined,
382
+ "strict",
383
+ );
384
+ expect(res.ok).toBe(false);
385
+ if (!res.ok) {
386
+ expect(res.diagnostics.map((d) => d.code)).toContain("link_unresolved");
387
+ }
388
+ // Nothing was written:
389
+ expect(existsSync(join(paths.wiki, "sources", "SRC-001.md"))).toBe(false);
390
+ expect(existsSync(join(paths.wiki, "entities", "alice.md"))).toBe(false);
391
+ });
392
+
393
+ it("warn writes and attaches wikilinkDiagnostics", () => {
394
+ const paths = getVaultPaths(wikiDir);
395
+ const res = commitSynthesis(
396
+ paths,
397
+ "SRC-001",
398
+ MANIFEST,
399
+ makeData("See [[ghost-page]] for details."),
400
+ "2026-06-06",
401
+ undefined,
402
+ "warn",
403
+ );
404
+ expect(res.ok).toBe(true);
405
+ if (res.ok) {
406
+ expect(res.wikilinkDiagnostics?.map((d) => d.code)).toContain("link_unresolved");
407
+ }
408
+ expect(existsSync(join(paths.wiki, "sources", "SRC-001.md"))).toBe(true);
409
+ });
410
+
411
+ it("normalize rewrites resolvable links to canonical ids in the written page", () => {
412
+ const paths = getVaultPaths(wikiDir);
413
+ // [[transformer]] resolves because makeData() creates a "Transformer" concept
414
+ // in the SAME commit — buildIngestAuditIndex adds concepts/transformer to the
415
+ // index, so no pre-existing page is needed (this also proves same-batch links resolve).
416
+ const res = commitSynthesis(
417
+ paths,
418
+ "SRC-001",
419
+ MANIFEST,
420
+ makeData("The [[transformer|T]] changed everything."),
421
+ "2026-06-06",
422
+ undefined,
423
+ "normalize",
424
+ );
425
+ expect(res.ok).toBe(true);
426
+ const written = readFileSync(join(paths.wiki, "sources", "SRC-001.md"), "utf-8");
427
+ expect(written).toContain("[[concepts/transformer|T]]");
428
+ });
429
+
430
+ it("off is a no-op (writes verbatim, no diagnostics)", () => {
431
+ const paths = getVaultPaths(wikiDir);
432
+ const res = commitSynthesis(
433
+ paths,
434
+ "SRC-001",
435
+ MANIFEST,
436
+ makeData("See [[ghost-page]] for details."),
437
+ "2026-06-06",
438
+ undefined,
439
+ "off",
440
+ );
441
+ expect(res.ok).toBe(true);
442
+ if (res.ok) expect(res.wikilinkDiagnostics).toBeUndefined();
443
+ const written = readFileSync(join(paths.wiki, "sources", "SRC-001.md"), "utf-8");
444
+ expect(written).toContain("[[ghost-page]]");
445
+ });
446
+
447
+ it("defaults to warn when mode is omitted", () => {
448
+ const paths = getVaultPaths(wikiDir);
449
+ const res = commitSynthesis(
450
+ paths,
451
+ "SRC-001",
452
+ MANIFEST,
453
+ makeData("See [[ghost-page]] for details."),
454
+ "2026-06-06",
455
+ );
456
+ expect(res.ok).toBe(true);
457
+ if (res.ok) expect(res.wikilinkDiagnostics?.length).toBeGreaterThan(0);
458
+ });
459
+ });
460
+ ```
461
+
462
+ - [ ] **Step 2: Run test to verify it fails**
463
+
464
+ Run: `pnpm vitest run test/ingest-worker.test.ts -t "wikilink gate"`
465
+ Expected: FAIL — `commitSynthesis` has no 7th param / `CommitResult` has no `wikilinkDiagnostics`.
466
+
467
+ - [ ] **Step 3: Write minimal implementation**
468
+
469
+ In `extensions/llm-wiki/lib/ingest-worker.ts`:
470
+
471
+ (a) Extend the imports. Add `readJson` to the existing `./utils.js` import line, and add a new import line for the gate:
472
+
473
+ ```ts
474
+ import { VaultPaths, fmtDate, slugify, readJson } from "./utils.js";
475
+ import { auditWikilinks, buildWikilinkIndex } from "./knowledge-links.js";
476
+ import { resolveWikilinkValidation, type WikilinkValidationMode } from "./task-config.js";
477
+ ```
478
+
479
+ > If `readJson` is already imported from `./utils.js` on that line, just add it to the existing import list rather than duplicating the line.
480
+
481
+ (b) Add `wikilinkDiagnostics?` to the `CommitResult` interface (after `contradictions: number;`):
482
+
483
+ ```ts
484
+ /** Wikilink gate diagnostics from `commitSynthesis` (warn/normalize modes). */
485
+ wikilinkDiagnostics?: KnowledgeDiagnostic[];
486
+ ```
487
+
488
+ (`KnowledgeDiagnostic` is already imported from `./knowledge-document.js` in this file.)
489
+
490
+ (c) Add a small local helper just above `commitSynthesis`:
491
+
492
+ ```ts
493
+ /**
494
+ * Build the wikilink index used by the pre-write gate: every existing page id
495
+ * (from the registry) plus the page ids this commit is about to create, so a
496
+ * link to a sibling created in the same ingest resolves instead of
497
+ * false-positiving as missing.
498
+ */
499
+ function buildIngestAuditIndex(
500
+ paths: VaultPaths,
501
+ sourceId: string,
502
+ data: SynthesisData,
503
+ ): ReturnType<typeof buildWikilinkIndex> {
504
+ const registry = readJson<{ pages: Record<string, unknown> }>(
505
+ join(paths.meta, "registry.json"),
506
+ { pages: {} },
507
+ );
508
+ const newIds = [
509
+ `sources/${sourceId}`,
510
+ ...data.entities
511
+ .filter((e) => slugify(e.title))
512
+ .map((e) => `entities/${slugify(e.title)}`),
513
+ ...data.concepts
514
+ .filter((c) => slugify(c.title))
515
+ .map((c) => `concepts/${slugify(c.title)}`),
516
+ ];
517
+ return buildWikilinkIndex([...Object.keys(registry.pages), ...newIds]);
518
+ }
519
+ ```
520
+
521
+ (d) Change the `commitSynthesis` signature — add the trailing `wikilinkValidation?` param:
522
+
523
+ ```ts
524
+ export function commitSynthesis(
525
+ paths: VaultPaths,
526
+ sourceId: string,
527
+ manifest: Record<string, unknown>,
528
+ data: SynthesisData,
529
+ date: string = fmtDate(),
530
+ lang?: string,
531
+ wikilinkValidation?: WikilinkValidationMode,
532
+ ): CommitSynthesisOutcome {
533
+ ```
534
+
535
+ (e) In the body, after the `assertWritableVault` try/catch block and before the `// Patch existing documents...` comment, compute the gated source body:
536
+
537
+ ```ts
538
+ // Pre-write wikilink gate (issue #172, Layer 2). Applies only to the
539
+ // model-authored source body; entity/concept pages are generated templates.
540
+ const mode = resolveWikilinkValidation({ wikilinkValidation });
541
+ let sourceBody = buildIngestedSourcePageBody(manifest, data, date, lang);
542
+ if (mode !== "off") {
543
+ const audit = auditWikilinks(sourceBody, buildIngestAuditIndex(paths, sourceId, data), sourceId, mode);
544
+ if (mode === "strict" && audit.diagnostics.length > 0) {
545
+ return { ok: false, sourceId, diagnostics: audit.diagnostics };
546
+ }
547
+ if (mode === "normalize") sourceBody = audit.body;
548
+ if (audit.diagnostics.length > 0) result.wikilinkDiagnostics = audit.diagnostics;
549
+ }
550
+ ```
551
+
552
+ (f) Replace both inline `buildIngestedSourcePageBody(manifest, data, date, lang)` calls in the source-document branch with `sourceBody`:
553
+
554
+ ```ts
555
+ sourceDocument = patchKnowledgeDocument(parsed.document, {
556
+ fields: { status: "ingested", updated: date },
557
+ body: sourceBody,
558
+ });
559
+ ```
560
+
561
+ and
562
+
563
+ ```ts
564
+ sourceBody,
565
+ ```
566
+
567
+ (as the third argument to `createKnowledgeDocument` in the `else` branch).
568
+
569
+ - [ ] **Step 4: Run test to verify it passes**
570
+
571
+ Run: `pnpm vitest run test/ingest-worker.test.ts`
572
+ Expected: PASS (existing `commitSynthesis` tests unchanged — they omit the 7th param and thus run in `warn` default, which still writes and is compatible with prior assertions; new gate tests pass).
573
+
574
+ > If an existing assertion breaks because a pre-existing test fixture now produces wikilink diagnostics that changed a count it asserts on, do NOT weaken the new gate — inspect the fixture. The only expected change is the new optional `wikilinkDiagnostics` field; existing asserted fields are untouched.
575
+
576
+ - [ ] **Step 5: Commit**
577
+
578
+ ```bash
579
+ git add extensions/llm-wiki/lib/ingest-worker.ts test/ingest-worker.test.ts
580
+ git commit -m "feat(wikilink): gate commitSynthesis writes by wikilinkValidation mode"
581
+ ```
582
+
583
+ ---
584
+
585
+ ### Task 4: Thread the mode through `runIngestSynthesis` + the tool report
586
+
587
+ **Files:**
588
+ - Modify: `extensions/llm-wiki/lib/ingest-worker.ts`
589
+ - Modify: `extensions/llm-wiki/lib/tools.ts`
590
+
591
+ - [ ] **Step 1: Add the arg to `RunIngestSynthesisArgs`**
592
+
593
+ In `ingest-worker.ts`, add to the `RunIngestSynthesisArgs` interface (after `synthesisMaxTokens?: number;`):
594
+
595
+ ```ts
596
+ /** Wikilink write-gate mode for the ingested source body (issue #172). */
597
+ wikilinkValidation?: WikilinkValidationMode;
598
+ ```
599
+
600
+ - [ ] **Step 2: Thread it into `commitSynthesis`**
601
+
602
+ In `runIngestSynthesis`, add `wikilinkValidation` to the `const { ... } = args;` destructuring block, and pass it as the 7th argument in the `commitSynthesis(...)` call inside `commitTool.execute`:
603
+
604
+ ```ts
605
+ const outcome = commitSynthesis(
606
+ paths,
607
+ sourceId,
608
+ manifest,
609
+ params,
610
+ undefined,
611
+ synthesisLanguage,
612
+ wikilinkValidation,
613
+ );
614
+ ```
615
+
616
+ - [ ] **Step 3: Pass the configured mode from the tool**
617
+
618
+ In `tools.ts`, in the `runtime.launchTask(...)` callback where `runIngestSynthesis({...})` is called, add the arg alongside the existing `synthesisLanguage`:
619
+
620
+ ```ts
621
+ synthesisLanguage: runtime.config.synthesisLanguage,
622
+ wikilinkValidation: runtime.config.wikilinkValidation,
623
+ ```
624
+
625
+ - [ ] **Step 4: Surface diagnostics in the report line**
626
+
627
+ In `tools.ts`, in the same `launchTask` callback, update the summary so warn/normalize diagnostics are visible. Replace the `const summary = committed ? ... : ...` block with:
628
+
629
+ ```ts
630
+ const wl = committed?.wikilinkDiagnostics?.length ?? 0;
631
+ const wlNote = wl > 0 ? `, ${wl} wikilink issue${wl === 1 ? "" : "s"}` : "";
632
+ const summary = committed
633
+ ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}${wlNote}`
634
+ : `LLM Wiki: ${s.id} produced no synthesis`;
635
+ ```
636
+
637
+ - [ ] **Step 5: Verify types + run affected tests**
638
+
639
+ Run: `pnpm typecheck && pnpm vitest run test/ingest-worker.test.ts test/ingest-tool.test.ts`
640
+ Expected: typecheck clean; tests PASS.
641
+
642
+ - [ ] **Step 6: Commit**
643
+
644
+ ```bash
645
+ git add extensions/llm-wiki/lib/ingest-worker.ts extensions/llm-wiki/lib/tools.ts
646
+ git commit -m "feat(wikilink): thread wikilinkValidation through runIngestSynthesis + report"
647
+ ```
648
+
649
+ ---
650
+
651
+ ### Task 5: Full gates + deploy note
652
+
653
+ **Files:** none (verification)
654
+
655
+ - [ ] **Step 1: Run the full suite + lint + typecheck**
656
+
657
+ Run: `pnpm typecheck && pnpm lint && pnpm test`
658
+ Expected: all green.
659
+
660
+ - [ ] **Step 2: Verify `build:commands` parity is unaffected**
661
+
662
+ Run: `pnpm build:commands && pnpm test`
663
+ Expected: no new prompt/parite failures (no `prompts/` files touched).
664
+
665
+ - [ ] **Step 3: Commit (if any test fixtures adjusted in Step 4 of Task 3)**
666
+
667
+ ```bash
668
+ git add -A && git commit -m "test(wikilink): green full suite"
669
+ ```
670
+
671
+ > **Deploy note (not a code step):** pi loads this extension from the source path (`../../code/pi-packages/pi-llm-wiki` in `~/.pi/agent/settings.json`). After merging, a **full pi restart** makes it live (a `/reload` does not swap the extension). Verify by ingesting a source whose summary contains a known-broken link and reading the **ingest report line** (`runtime.report(...)`) — it should now append `N wikilink issue(s)` (the Task 4 feature). Do NOT verify via `/wiki-lint`: lint already surfaced broken links before this change and will not behave differently. To enable blocking, set `"wikilinkValidation": "strict"` (or `"normalize"`) under the `llm-wiki` key in settings.
672
+
673
+ ---
674
+
675
+ ## Self-Review
676
+
677
+ **Spec/roadmap coverage:**
678
+ - #172 "pre-write wikilink validation and normalization" → Task 2 (helper) + Task 3 (gate at write) + Task 1 (config) + Task 4 (wiring/report). ✅
679
+ - Modes `off|warn|strict|normalize` → Task 1 resolver + Task 2 helper + Task 3 gate. ✅
680
+ - Default `warn` (user decision) → Task 1 resolver. ✅
681
+ - **Settings → config flow** (so a user setting actually reaches the gate) → Task 1 Step 3(e) `readNamespacedConfig` branch + the settings-namespace flow test in Task 1 Step 1. ✅
682
+ - Ingest-only scope, source-body-only gate (decided) → Task 3 (documented in scope). ✅
683
+
684
+ **Placeholder scan:** All steps contain exact code, exact commands, and expected output. No "TBD"/"add appropriate handling". ✅
685
+
686
+ **Type consistency:**
687
+ - `WikilinkValidationMode` defined once (Task 1, `knowledge-links.ts`); Task 1 imports it into `task-config.ts`; Task 2 uses it in `auditWikilinks`; Task 3 imports it into `ingest-worker.ts`. One definition, no drift. ✅
688
+ - `auditWikilinks(body, index, sourceId, mode): WikilinkAuditResult` signature identical in Task 2 def and Task 3 usage. ✅
689
+ - `commitSynthesis` 7th param `wikilinkValidation?: WikilinkValidationMode` consistent across def (Task 3) and `runIngestSynthesis` call (Task 4). ✅
690
+ - `CommitResult.wikilinkDiagnostics?: KnowledgeDiagnostic[]` consistent across Task 3 (def + set) and Task 4 (report read `committed?.wikilinkDiagnostics?.length`). ✅
691
+ - `resolveWikilinkValidation(config?)` Task 1 def; Task 3 calls `resolveWikilinkValidation({ wikilinkValidation })` — valid (accepts `TaskConfig | undefined`). ✅
692
+ - `resolveWikilink` API: Task 2 uses the real `kind`/`id` union (NOT `status`/`canonicalId`) — matches `knowledge-links.ts` `WikilinkResolution`. ✅
693
+ - `readNamespacedConfig` branch (Task 1 Step 3e) uses the same `WIKILINK_VALIDATION_MODES` const and sets `out.wikilinkValidation`, matching the `TaskConfig` field. ✅
694
+
695
+ **Phase boundary health:** Single plan; after Task 5 the feature is complete, default `warn` is non-destructive (writes always proceed, only reports), and all gates are green. No half-migrations: the new config field is optional with a default; the new `commitSynthesis` param is optional; `CommitResult.wikilinkDiagnostics` is optional. Existing callers omitting the new params keep prior behavior (default `warn`, which still writes). ✅