@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,642 @@
1
+ # Wikilink Gate on `wiki_ensure_page` / `wiki_retro` (#172 re-target) 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:** Apply the pre-write wikilink gate to the two direct-content tools `wiki_ensure_page` and `wiki_retro` — the path issue #172 actually names — reusing the already-built `auditWikilinks` resolver and the `wikilinkValidation` setting (modes `off|warn|strict|normalize`, default `warn`).
6
+
7
+ **Architecture:** A new small pure helper `applyWikilinkGate(body, index, sourceId, mode)` in `knowledge-links.ts` wraps `auditWikilinks` and returns `{ ok, body, diagnostics }`: `ok:false` only when `mode === "strict"` and there are unresolvable/ambiguous links; `body` is the normalized form when `mode === "normalize"`. Each tool's `execute` builds the index from `meta/registry.json` keys (same source the ingest gate uses), runs the gate on the caller-supplied body, and per result: strict → return `isError` (no write); normalize → write the normalized body; warn → write and append a note to the return. The mode is read via `resolveWikilinkValidation(loadTaskConfig(cwd))`.
8
+
9
+ **Composition with Layer 1 (important):** a wikilink that *resolves* (even if bare-title/case-drifted) is never flagged — it is only rewritten in `normalize` mode. Only genuinely **missing** or **ambiguous** targets produce diagnostics. So `strict` blocks only on real gaps, not on cosmetic drift.
10
+
11
+ **Tech Stack:** TypeScript (ESM), Vitest, existing `auditWikilinks` / `buildWikilinkIndex` / `resolveWikilink` / `readJson` / `loadTaskConfig`.
12
+
13
+ **Roadmap:** None
14
+
15
+ **Phase:** Single-plan follow-up to #172 (Layer 2a — ingest gate — is already merged and stays).
16
+
17
+ ---
18
+
19
+ ## Scope (decided with user)
20
+
21
+ - **Keep all four modes**, default **`warn`** (non-mutating, non-blocking, surfaces issues — safe + useful).
22
+ - **Keep the existing ingest gate** (already merged, tested, non-destructive) — this plan adds the direct-tool gate on top.
23
+ - **New write paths gated:** `wiki_ensure_page` (tools.ts) and `wiki_retro` (retro.ts). These are where a human/agent supplies `content`/`body` verbatim — the issue's exact target.
24
+ - **Two-axis doc comment** added to `WikilinkValidationMode` so the modes aren't misread as a severity ladder.
25
+ - **MCP path out of scope:** `retroOperation` (mcp/operations.ts) is not gated here; the gate sits at the Pi tool boundary (where the issue's agent caller lives). MCP coverage is a follow-up.
26
+
27
+ ## File Structure
28
+
29
+ - **Modify** `extensions/llm-wiki/lib/knowledge-links.ts` — add `applyWikilinkGate()` + `WikilinkGateResult`; add the two-axis doc comment to `WikilinkValidationMode`.
30
+ - **Modify** `extensions/llm-wiki/lib/tools.ts` — gate `wiki_ensure_page` in `registerWikiEnsurePage`.
31
+ - **Modify** `extensions/llm-wiki/lib/retro.ts` — gate `wiki_retro` in `registerWikiRetro`.
32
+ - **Test** `test/knowledge-links.test.ts` (append — `applyWikilinkGate` unit tests).
33
+ - **Test** `test/wikilink-gate.test.ts` (create — `wiki_ensure_page` + `wiki_retro` gate integration tests).
34
+
35
+ ---
36
+
37
+ ### Task 1: `applyWikilinkGate` helper + two-axis doc
38
+
39
+ **Files:**
40
+ - Modify: `extensions/llm-wiki/lib/knowledge-links.ts`
41
+ - Test: `test/knowledge-links.test.ts` (append)
42
+
43
+ **Context (already in this file):** `auditWikilinks(body, index, sourceId, mode): { diagnostics, body, changed }`, `WikilinkIndex`, `buildWikilinkIndex(ids)`, `KnowledgeDiagnostic`, and `WikilinkValidationMode` (`"off" | "warn" | "strict" | "normalize"`).
44
+
45
+ - [ ] **Step 1: Write the failing test**
46
+
47
+ Append to `test/knowledge-links.test.ts`:
48
+
49
+ ```ts
50
+ import { applyWikilinkGate } from "../extensions/llm-wiki/lib/knowledge-links.js";
51
+
52
+ const gateIdx = buildWikilinkIndex(["concepts/transformer"]);
53
+
54
+ describe("applyWikilinkGate", () => {
55
+ const body = "see [[transformer]] and [[ghost]]";
56
+
57
+ it("off → ok, unchanged body, no diagnostics", () => {
58
+ const r = applyWikilinkGate(body, gateIdx, "x", "off");
59
+ expect(r.ok).toBe(true);
60
+ expect(r.body).toBe(body);
61
+ expect(r.diagnostics).toEqual([]);
62
+ });
63
+
64
+ it("warn → ok, unchanged body, reports only the missing link", () => {
65
+ const r = applyWikilinkGate(body, gateIdx, "x", "warn");
66
+ expect(r.ok).toBe(true);
67
+ expect(r.body).toBe(body); // not rewritten
68
+ const codes = r.diagnostics.map((d) => d.code);
69
+ expect(codes).toEqual(["link_unresolved"]); // [[ghost]] only; [[transformer]] resolves
70
+ });
71
+
72
+ it("strict → not ok when a link is missing", () => {
73
+ const r = applyWikilinkGate(body, gateIdx, "x", "strict");
74
+ expect(r.ok).toBe(false);
75
+ expect(r.diagnostics.length).toBe(1);
76
+ });
77
+
78
+ it("strict → ok when every link resolves", () => {
79
+ const r = applyWikilinkGate("see [[transformer]]", gateIdx, "x", "strict");
80
+ expect(r.ok).toBe(true);
81
+ expect(r.diagnostics).toEqual([]);
82
+ });
83
+
84
+ it("normalize → rewrites resolvable links, still reports missing", () => {
85
+ const r = applyWikilinkGate(body, gateIdx, "x", "normalize");
86
+ expect(r.ok).toBe(true);
87
+ expect(r.body).toBe("see [[concepts/transformer]] and [[ghost]]");
88
+ expect(r.diagnostics.map((d) => d.code)).toEqual(["link_unresolved"]);
89
+ });
90
+ });
91
+ ```
92
+
93
+ - [ ] **Step 2: Run test to verify it fails**
94
+
95
+ Run: `pnpm vitest run test/knowledge-links.test.ts -t applyWikilinkGate`
96
+ Expected: FAIL — `applyWikilinkGate` is not exported.
97
+
98
+ - [ ] **Step 3: Write minimal implementation**
99
+
100
+ In `extensions/llm-wiki/lib/knowledge-links.ts`:
101
+
102
+ (a) Replace the existing `WikilinkValidationMode` type declaration (the single line `export type WikilinkValidationMode = "off" | "warn" | "strict" | "normalize";` added in Task 1 of the earlier plan) with a documented version:
103
+
104
+ ```ts
105
+ /**
106
+ * Pre-write wikilink gate mode (issue #172). Two independent behaviors, not a
107
+ * severity ladder:
108
+ * 1. resolvable-but-drifted links (target exists): leave vs. rewrite-to-canonical
109
+ * 2. unresolvable links (target absent — a forward reference / gap): ignore vs. report vs. reject
110
+ *
111
+ * off = leave + ignore (opt-out; zero behavior change)
112
+ * warn = leave + report (default; non-mutating, non-blocking, surfaces issues)
113
+ * normalize = rewrite + report (fixes resolvable links; still reports gaps)
114
+ * strict = leave + reject (blocks the write with the bad links named; agent retry signal)
115
+ *
116
+ * A link that RESOLVES is never flagged — only normalized. Only missing/ambiguous targets
117
+ * produce diagnostics.
118
+ */
119
+ export type WikilinkValidationMode = "off" | "warn" | "strict" | "normalize";
120
+ ```
121
+
122
+ (b) Append this helper at the end of the file (after `auditWikilinks`):
123
+
124
+ ```ts
125
+ export interface WikilinkGateResult {
126
+ /** false only when mode === "strict" AND there are unresolvable/ambiguous links. */
127
+ ok: boolean;
128
+ /** The body to write (normalized when mode === "normalize", else the input). */
129
+ body: string;
130
+ /** Unresolved / ambiguous link diagnostics (empty for "off" / clean bodies). */
131
+ diagnostics: KnowledgeDiagnostic[];
132
+ }
133
+
134
+ /**
135
+ * Apply the pre-write wikilink gate to a body. Wraps {@link auditWikilinks}:
136
+ * blocks (ok:false) only in strict mode with issues, rewrites in normalize mode,
137
+ * and always returns the diagnostics so callers can surface them (warn/normalize).
138
+ */
139
+ export function applyWikilinkGate(
140
+ body: string,
141
+ index: WikilinkIndex,
142
+ sourceId: string,
143
+ mode: WikilinkValidationMode,
144
+ ): WikilinkGateResult {
145
+ if (mode === "off") return { ok: true, body, diagnostics: [] };
146
+ const audit = auditWikilinks(body, index, sourceId, mode);
147
+ return {
148
+ ok: !(mode === "strict" && audit.diagnostics.length > 0),
149
+ body: mode === "normalize" ? audit.body : body,
150
+ diagnostics: audit.diagnostics,
151
+ };
152
+ }
153
+ ```
154
+
155
+ - [ ] **Step 4: Run test to verify it passes**
156
+
157
+ Run: `pnpm vitest run test/knowledge-links.test.ts`
158
+ Expected: PASS (all existing + new `applyWikilinkGate` tests).
159
+
160
+ - [ ] **Step 5: Commit**
161
+
162
+ ```bash
163
+ git add extensions/llm-wiki/lib/knowledge-links.ts test/knowledge-links.test.ts
164
+ git commit -m "feat(wikilink): add applyWikilinkGate helper + document the two-axis modes"
165
+ ```
166
+
167
+ ---
168
+
169
+ ### Task 2: Gate `wiki_ensure_page`
170
+
171
+ **Files:**
172
+ - Modify: `extensions/llm-wiki/lib/tools.ts`
173
+ - Test: `test/wikilink-gate.test.ts` (create — this task adds the `wiki_ensure_page` describe block; Task 3 adds the `wiki_retro` block to the same file)
174
+
175
+ **Context:** In `registerWikiEnsurePage`'s `execute`, the current flow computes `const body = params.content ?? buildPageBody(type, params.title);` then `createKnowledgeDocument(..., body)` then `writeKnowledgeDocumentFile`. `paths` (VaultPaths) is in scope; `ctx.cwd` is the vault root. `readJson` and `join` are already imported in tools.ts. The tool returns `{ content: [{ type:"text", text }], details }`.
176
+
177
+ - [ ] **Step 1: Write the failing test**
178
+
179
+ Create `test/wikilink-gate.test.ts`:
180
+
181
+ ```ts
182
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
183
+ import { join } from "node:path";
184
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
185
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
186
+ import { registerWikiEnsurePage } from "../extensions/llm-wiki/lib/tools.js";
187
+ import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
188
+
189
+ interface Tool {
190
+ execute: (
191
+ id: string,
192
+ params: Record<string, unknown>,
193
+ s: undefined,
194
+ u: undefined,
195
+ ctx: unknown,
196
+ ) => Promise<{ isError?: boolean; content: Array<{ text: string }>; details: Record<string, unknown> }>;
197
+ }
198
+
199
+ function capture(fn: (pi: ExtensionAPI) => void): Tool {
200
+ let tool: Tool | undefined;
201
+ const pi = { registerTool: (def: unknown) => (tool = def as Tool) } as unknown as ExtensionAPI;
202
+ fn(pi);
203
+ if (!tool) throw new Error("tool not registered");
204
+ return tool;
205
+ }
206
+
207
+ let wikiDir: string;
208
+
209
+ beforeEach(() => {
210
+ wikiDir = join(
211
+ import.meta.dirname,
212
+ "..",
213
+ "tmp",
214
+ `wg-${Date.now()}-${Math.random().toString(36).slice(2)}`,
215
+ );
216
+ const llm = join(wikiDir, ".llm-wiki");
217
+ for (const d of ["wiki/entities", "wiki/concepts", "wiki/sources", "meta", "outputs"]) {
218
+ mkdirSync(join(llm, d), { recursive: true });
219
+ }
220
+ // config.json is required — both tools call inspectWritableVault, which hard-blocks
221
+ // on an absent/unreadable config (config_invalid_knowledge_format). Mirror retro.test.ts.
222
+ writeFileSync(
223
+ join(llm, "config.json"),
224
+ JSON.stringify({ topic: "Test", mode: "personal" }),
225
+ );
226
+ ensureVaultStructure(getVaultPaths(wikiDir));
227
+ // Seed the registry with one resolvable target so [[transformer]] resolves and [[ghost]] does not.
228
+ writeFileSync(
229
+ join(llm, "meta", "registry.json"),
230
+ JSON.stringify({
231
+ version: "1.0",
232
+ last_updated: "",
233
+ pages: { "concepts/transformer": { id: "concepts/transformer", title: "Transformer", type: "concept" } },
234
+ }),
235
+ );
236
+ // No .pi/settings.json here → default mode is "warn".
237
+ });
238
+
239
+ afterEach(() => {
240
+ try {
241
+ rmSync(wikiDir, { recursive: true, force: true }); // only this test's own root, never the shared test/tmp
242
+ } catch {}
243
+ });
244
+
245
+ function setMode(mode: string): void {
246
+ const cfg = join(wikiDir, ".pi");
247
+ mkdirSync(cfg, { recursive: true });
248
+ writeFileSync(join(cfg, "settings.json"), JSON.stringify({ "llm-wiki": { wikilinkValidation: mode } }));
249
+ }
250
+
251
+ describe("wiki_ensure_page wikilink gate", () => {
252
+ const content = "see [[transformer]] and [[ghost]]";
253
+
254
+ it("off → writes verbatim, no issues surfaced", async () => {
255
+ setMode("off");
256
+ const tool = capture((pi) => registerWikiEnsurePage(pi));
257
+ const res = await tool.execute(
258
+ "t",
259
+ { type: "concept", title: "Ghost Concept", content },
260
+ undefined,
261
+ undefined,
262
+ { cwd: wikiDir, hasUI: false },
263
+ );
264
+ expect(res.isError).toBeFalsy();
265
+ const file = join(getVaultPaths(wikiDir).wiki, "concepts", "ghost-concept.md");
266
+ expect(existsSync(file)).toBe(true);
267
+ expect(res.details.wikilinkIssues).toEqual([]);
268
+ });
269
+
270
+ it("warn → writes, reports the missing link (transformer resolves, not reported)", async () => {
271
+ const tool = capture((pi) => registerWikiEnsurePage(pi)); // default warn
272
+ const res = await tool.execute(
273
+ "t",
274
+ { type: "concept", title: "Ghost Concept", content },
275
+ undefined,
276
+ undefined,
277
+ { cwd: wikiDir, hasUI: false },
278
+ );
279
+ expect(res.isError).toBeFalsy();
280
+ const issues = res.details.wikilinkIssues as string[];
281
+ expect(issues.length).toBe(1);
282
+ expect(issues[0]).toContain("ghost");
283
+ });
284
+
285
+ it("strict → rejects, writes nothing", async () => {
286
+ setMode("strict");
287
+ const tool = capture((pi) => registerWikiEnsurePage(pi));
288
+ const res = await tool.execute(
289
+ "t",
290
+ { type: "concept", title: "Ghost Concept", content },
291
+ undefined,
292
+ undefined,
293
+ { cwd: wikiDir, hasUI: false },
294
+ );
295
+ expect(res.isError).toBe(true);
296
+ expect(res.details.error).toBe("link_validation");
297
+ expect(existsSync(join(getVaultPaths(wikiDir).wiki, "concepts", "ghost-concept.md"))).toBe(false);
298
+ });
299
+
300
+ it("normalize → rewrites resolvable link, reports the missing one", async () => {
301
+ setMode("normalize");
302
+ const tool = capture((pi) => registerWikiEnsurePage(pi));
303
+ const res = await tool.execute(
304
+ "t",
305
+ { type: "concept", title: "Ghost Concept", content },
306
+ undefined,
307
+ undefined,
308
+ { cwd: wikiDir, hasUI: false },
309
+ );
310
+ expect(res.isError).toBeFalsy();
311
+ const file = join(getVaultPaths(wikiDir).wiki, "concepts", "ghost-concept.md");
312
+ const text = readFileSync(file, "utf-8");
313
+ expect(text).toContain("[[concepts/transformer]]");
314
+ expect(text).toContain("[[ghost]]");
315
+ });
316
+ });
317
+ ```
318
+
319
+ > Note: add `readFileSync` to the `node:fs` import at the top (it is used in the `normalize` test).
320
+
321
+ - [ ] **Step 2: Run test to verify it fails**
322
+
323
+ Run: `pnpm vitest run test/wikilink-gate.test.ts`
324
+ Expected: FAIL — the `wiki_ensure_page` gate is not implemented (`res.details.wikilinkIssues` undefined; strict does not reject; normalize does not rewrite).
325
+
326
+ - [ ] **Step 3: Write minimal implementation**
327
+
328
+ In `extensions/llm-wiki/lib/tools.ts`:
329
+
330
+ (a) Extend imports. Add `loadTaskConfig, resolveWikilinkValidation` to the existing task-config import line (currently `import { parseModelRef } from "./task-config.js";`):
331
+
332
+ ```ts
333
+ import { parseModelRef, loadTaskConfig, resolveWikilinkValidation } from "./task-config.js";
334
+ ```
335
+
336
+ Add a new import line (near the other `./` imports):
337
+
338
+ ```ts
339
+ import { applyWikilinkGate, buildWikilinkIndex } from "./knowledge-links.js";
340
+ ```
341
+
342
+ > `readJson` and `join` are already imported in tools.ts — do not re-import them.
343
+
344
+ (b) In `registerWikiEnsurePage`'s `execute`, change `const body = params.content ?? buildPageBody(type, params.title);` to `let body = ...` and insert the gate immediately after it (before `const doc = createKnowledgeDocument(...)`):
345
+
346
+ ```ts
347
+ let body = params.content ?? buildPageBody(type, params.title);
348
+
349
+ // Pre-write wikilink gate (#172): validate/normalize caller-supplied content.
350
+ const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd));
351
+ let wikilinkIssues: string[] = [];
352
+ if (mode !== "off") {
353
+ const registry = readJson<{ pages: Record<string, unknown> }>(
354
+ join(paths.meta, "registry.json"),
355
+ { pages: {} },
356
+ );
357
+ const gate = applyWikilinkGate(
358
+ body,
359
+ buildWikilinkIndex(Object.keys(registry.pages)),
360
+ `${folder}/${slug}`,
361
+ mode,
362
+ );
363
+ wikilinkIssues = gate.diagnostics.map((d) => d.message);
364
+ if (!gate.ok) {
365
+ return {
366
+ content: [
367
+ {
368
+ type: "text",
369
+ text: `Rejected write — unresolved/ambiguous wikilinks:\n${wikilinkIssues
370
+ .map((m) => `- ${m}`)
371
+ .join("\n")}`,
372
+ },
373
+ ],
374
+ details: { error: "link_validation", issues: wikilinkIssues } as Record<string, unknown>,
375
+ isError: true,
376
+ };
377
+ }
378
+ if (mode === "normalize") body = gate.body;
379
+ }
380
+ ```
381
+
382
+ (c) In the success `return` of `registerWikiEnsurePage`, append the note and the `wikilinkIssues` detail. Replace:
383
+
384
+ ```ts
385
+ return {
386
+ content: [{ type: "text", text: `✅ Created ${type} page: \`${pagePath}\`` }],
387
+ details: { path: pagePath, created: true } as Record<string, unknown>,
388
+ };
389
+ ```
390
+
391
+ with:
392
+
393
+ ```ts
394
+ const gateNote = wikilinkIssues.length
395
+ ? `\n\n⚠️ ${wikilinkIssues.length} wikilink issue(s):\n${wikilinkIssues.map((m) => `- ${m}`).join("\n")}`
396
+ : "";
397
+ return {
398
+ content: [
399
+ { type: "text", text: `✅ Created ${type} page: \`${pagePath}\`${gateNote}` },
400
+ ],
401
+ details: {
402
+ path: pagePath,
403
+ created: true,
404
+ wikilinkIssues,
405
+ } as Record<string, unknown>,
406
+ };
407
+ ```
408
+
409
+ - [ ] **Step 4: Run test to verify it passes**
410
+
411
+ Run: `pnpm vitest run test/wikilink-gate.test.ts`
412
+ Expected: PASS (4 `wiki_ensure_page` tests).
413
+
414
+ - [ ] **Step 5: Commit**
415
+
416
+ ```bash
417
+ git add extensions/llm-wiki/lib/tools.ts test/wikilink-gate.test.ts
418
+ git commit -m "feat(wikilink): gate wiki_ensure_page writes by wikilinkValidation mode"
419
+ ```
420
+
421
+ ---
422
+
423
+ ### Task 3: Gate `wiki_retro`
424
+
425
+ **Files:**
426
+ - Modify: `extensions/llm-wiki/lib/retro.ts`
427
+ - Test: `test/wikilink-gate.test.ts` (append the `wiki_retro` describe block)
428
+
429
+ **Context:** In `registerWikiRetro`'s `execute`, the flow resolves `paths = resolveVaultPaths(ctx.cwd ?? process.cwd())`, then `result = saveInsight(paths, params.slug, params.title, params.body, params.category, { rebuild: !runtime })`, then returns a multi-line success message. `params.body` is the caller-supplied markdown (the gate target). The success `details` currently is `{ slug, title, category }`.
430
+
431
+ - [ ] **Step 1: Write the failing test**
432
+
433
+ Append to `test/wikilink-gate.test.ts`. Add `registerWikiRetro` to the imports and `readFileSync` is already imported. Append this block at the end of the file (it reuses the same `beforeEach` vault + `setMode` + `capture` helpers):
434
+
435
+ ```ts
436
+ import { registerWikiRetro } from "../extensions/llm-wiki/lib/retro.js";
437
+
438
+ describe("wiki_retro wikilink gate", () => {
439
+ const body = "Learned about [[transformer]] and [[ghost]]";
440
+
441
+ it("warn (default) → saves, reports the missing link", async () => {
442
+ const tool = capture((pi) => registerWikiRetro(pi));
443
+ const res = await tool.execute(
444
+ "t",
445
+ { slug: "transformer-note", title: "Transformer Note", body },
446
+ undefined,
447
+ undefined,
448
+ { cwd: wikiDir, hasUI: false },
449
+ );
450
+ expect(res.isError).toBeFalsy();
451
+ const issues = res.details.wikilinkIssues as string[];
452
+ expect(issues.length).toBe(1);
453
+ expect(issues[0]).toContain("ghost");
454
+ expect(existsSync(join(getVaultPaths(wikiDir).wiki, "sources", "transformer-note.md"))).toBe(true);
455
+ });
456
+
457
+ it("strict → rejects, writes nothing", async () => {
458
+ setMode("strict");
459
+ const tool = capture((pi) => registerWikiRetro(pi));
460
+ const res = await tool.execute(
461
+ "t",
462
+ { slug: "transformer-note", title: "Transformer Note", body },
463
+ undefined,
464
+ undefined,
465
+ { cwd: wikiDir, hasUI: false },
466
+ );
467
+ expect(res.isError).toBe(true);
468
+ expect(res.details.error).toBe("link_validation");
469
+ expect(existsSync(join(getVaultPaths(wikiDir).wiki, "sources", "transformer-note.md"))).toBe(false);
470
+ });
471
+
472
+ it("normalize → saves with the resolvable link rewritten", async () => {
473
+ setMode("normalize");
474
+ const tool = capture((pi) => registerWikiRetro(pi));
475
+ const res = await tool.execute(
476
+ "t",
477
+ { slug: "transformer-note", title: "Transformer Note", body },
478
+ undefined,
479
+ undefined,
480
+ { cwd: wikiDir, hasUI: false },
481
+ );
482
+ expect(res.isError).toBeFalsy();
483
+ const text = readFileSync(join(getVaultPaths(wikiDir).wiki, "sources", "transformer-note.md"), "utf-8");
484
+ expect(text).toContain("[[concepts/transformer]]");
485
+ expect(text).toContain("[[ghost]]");
486
+ });
487
+ });
488
+ ```
489
+
490
+ - [ ] **Step 2: Run test to verify it fails**
491
+
492
+ Run: `pnpm vitest run test/wikilink-gate.test.ts -t "wiki_retro wikilink gate"`
493
+ Expected: FAIL — the `wiki_retro` gate is not implemented.
494
+
495
+ - [ ] **Step 3: Write minimal implementation**
496
+
497
+ In `extensions/llm-wiki/lib/retro.ts`:
498
+
499
+ (a) Imports. Make these exact changes (retro.ts currently has `import { dirname, resolve } from "node:path";` on line 1 and `import { type VaultPaths, fmtDate, resolveVaultPaths } from "./utils.js";` on line 8):
500
+ - Line 1: `import { dirname, resolve } from "node:path";` → `import { dirname, join, resolve } from "node:path";` (add `join`).
501
+ - Line 8: add `readJson` → `import { type VaultPaths, fmtDate, readJson, resolveVaultPaths } from "./utils.js";`
502
+ - Add two new import lines near the other `./` imports:
503
+
504
+ ```ts
505
+ import { applyWikilinkGate, buildWikilinkIndex } from "./knowledge-links.js";
506
+ import { loadTaskConfig, resolveWikilinkValidation } from "./task-config.js";
507
+ ```
508
+
509
+ No `node:fs` import is needed — the gate only uses `readJson` (from utils) and `join` (from node:path).
510
+
511
+ (b) In `registerWikiRetro`'s `execute`, insert the gate BEFORE the `try { result = saveInsight(...); }` block, and thread the (possibly normalized) body into `saveInsight`. Insert before the `let result: RetroResult;` line:
512
+
513
+ ```ts
514
+ // Pre-write wikilink gate (#172): validate/normalize caller-supplied body.
515
+ const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd ?? process.cwd()));
516
+ let body = params.body;
517
+ let wikilinkIssues: string[] = [];
518
+ if (mode !== "off") {
519
+ const registry = readJson<{ pages: Record<string, unknown> }>(
520
+ join(paths.meta, "registry.json"),
521
+ { pages: {} },
522
+ );
523
+ const gate = applyWikilinkGate(
524
+ body,
525
+ buildWikilinkIndex(Object.keys(registry.pages)),
526
+ `sources/${params.slug}`,
527
+ mode,
528
+ );
529
+ wikilinkIssues = gate.diagnostics.map((d) => d.message);
530
+ if (!gate.ok) {
531
+ return {
532
+ content: [
533
+ {
534
+ type: "text",
535
+ text: `Rejected write — unresolved/ambiguous wikilinks:\n${wikilinkIssues
536
+ .map((m) => `- ${m}`)
537
+ .join("\n")}`,
538
+ },
539
+ ],
540
+ details: { error: "link_validation", issues: wikilinkIssues } as Record<string, unknown>,
541
+ isError: true,
542
+ };
543
+ }
544
+ if (mode === "normalize") body = gate.body;
545
+ }
546
+ ```
547
+
548
+ Then change the `saveInsight` call to use `body` instead of `params.body`:
549
+
550
+ ```ts
551
+ result = saveInsight(paths, params.slug, params.title, body, params.category, {
552
+ rebuild: !runtime,
553
+ });
554
+ ```
555
+
556
+ (c) In the success `return`, append the note and `wikilinkIssues`. Replace the text array join and details:
557
+
558
+ ```ts
559
+ const gateNote = wikilinkIssues.length
560
+ ? `\n\n⚠️ ${wikilinkIssues.length} wikilink issue(s):\n${wikilinkIssues.map((m) => `- ${m}`).join("\n")}`
561
+ : "";
562
+ return {
563
+ content: [
564
+ {
565
+ type: "text",
566
+ text: [
567
+ `🧠 **Insight saved**: ${params.title}`,
568
+ "",
569
+ `- Page: \`${result.sourcePagePath}\``,
570
+ "",
571
+ "This insight will be auto-surfaced by wiki_recall in future sessions.",
572
+ gateNote,
573
+ ]
574
+ .filter(Boolean)
575
+ .join("\n"),
576
+ },
577
+ ],
578
+ details: {
579
+ slug: params.slug,
580
+ title: params.title,
581
+ category: params.category || null,
582
+ wikilinkIssues,
583
+ } as Record<string, unknown>,
584
+ };
585
+ ```
586
+
587
+ - [ ] **Step 4: Run test to verify it passes**
588
+
589
+ Run: `pnpm vitest run test/wikilink-gate.test.ts`
590
+ Expected: PASS (all `wiki_ensure_page` + `wiki_retro` gate tests).
591
+
592
+ - [ ] **Step 5: Commit**
593
+
594
+ ```bash
595
+ git add extensions/llm-wiki/lib/retro.ts test/wikilink-gate.test.ts
596
+ git commit -m "feat(wikilink): gate wiki_retro writes by wikilinkValidation mode"
597
+ ```
598
+
599
+ ---
600
+
601
+ ### Task 4: Full gates
602
+
603
+ **Files:** none (verification)
604
+
605
+ - [ ] **Step 1: Full suite + lint + typecheck**
606
+
607
+ Run: `pnpm typecheck && pnpm lint && pnpm test`
608
+ Expected: all green. Watch that pre-existing `wiki_retro`/`wiki_ensure_page` tests still pass (they omit a mode → default `warn`, which still writes and only adds an empty `wikilinkIssues` detail).
609
+
610
+ - [ ] **Step 2: Format + build:commands parity**
611
+
612
+ Run: `npx @biomejs/biome check --write extensions/llm-wiki/lib/knowledge-links.ts extensions/llm-wiki/lib/tools.ts extensions/llm-wiki/lib/retro.ts test/wikilink-gate.test.ts test/knowledge-links.test.ts && pnpm build:commands && pnpm test`
613
+ Expected: clean; re-run tests after the format pass.
614
+
615
+ - [ ] **Step 3: Commit if the format pass changed anything**
616
+
617
+ ```bash
618
+ git add -A && git commit -m "style(wikilink): biome format on ensure_page/retro gate files"
619
+ ```
620
+
621
+ > **Deploy note (not a code step):** pi loads this from the source path. After merging, a **full pi restart** makes it live. Verify by calling `wiki_ensure_page` (or `wiki_retro`) with a body containing a link to a nonexistent page and confirming the return names the issue (default `warn`). To block, set `"wikilinkValidation": "strict"` under the `llm-wiki` key in settings.
622
+
623
+ ---
624
+
625
+ ## Self-Review
626
+
627
+ **Spec/roadmap coverage:**
628
+ - #172 "pre-write link validation for `wiki_ensure_page` / `wiki_retro`" → Task 2 (ensure_page) + Task 3 (retro) + Task 1 (shared helper). ✅
629
+ - All four modes, default `warn` → Task 1 helper + Tasks 2–3 wiring (reuses existing `resolveWikilinkValidation` default). ✅
630
+ - Two-axis doc comment → Task 1 Step 3(a). ✅
631
+ - Composition with Layer 1 (resolvable links normalized, not flagged) → Task 1 tests assert this; encoded in `auditWikilinks` (unchanged). ✅
632
+ - Ingest gate preserved (not touched) → out of this plan's edits. ✅
633
+
634
+ **Placeholder scan:** Every step has exact code, exact commands, expected output. No TBD/TODO. ✅
635
+
636
+ **Type consistency:**
637
+ - `applyWikilinkGate(body, index, sourceId, mode): WikilinkGateResult` defined once (Task 1) and used identically in Tasks 2–3. ✅
638
+ - `WikilinkGateResult` = `{ ok, body, diagnostics }` consistent across def and both call sites. ✅
639
+ - Reuses existing `auditWikilinks` / `buildWikilinkIndex` / `resolveWikilinkValidation` / `loadTaskConfig` / `readJson` — no redefinition. ✅
640
+ - Success-detail field `wikilinkIssues: string[]` consistent between both tools and the tests. ✅
641
+
642
+ **Phase boundary health:** Single plan; after Task 4 the direct-tool gate is complete, default `warn` is non-destructive (writes always proceed, only reports), all gates green. New behavior is additive: the only new thing on the default path is an (often empty) `wikilinkIssues` detail + an optional report note. Pre-existing tests pass because `warn` never blocks or mutates. MCP `retroOperation` is intentionally out of scope (follow-up). ✅