From 97a6b91f9525ca5df283b60bf00fb6e09f147776 Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Mon, 11 May 2026 21:54:25 -0700 Subject: [PATCH] Validate wiki refs before writes --- .../context/skills/knowledge_capture/SKILL.md | 2 +- .../src/wiki/tools/wiki-read.tool.test.ts | 20 +++++ .../context/src/wiki/tools/wiki-read.tool.ts | 9 +- .../src/wiki/tools/wiki-write.tool.test.ts | 44 ++++++++++ .../context/src/wiki/tools/wiki-write.tool.ts | 82 +++++++++++++++++++ 5 files changed, 149 insertions(+), 8 deletions(-) diff --git a/packages/context/skills/knowledge_capture/SKILL.md b/packages/context/skills/knowledge_capture/SKILL.md index ba6fc125..f74e3f04 100644 --- a/packages/context/skills/knowledge_capture/SKILL.md +++ b/packages/context/skills/knowledge_capture/SKILL.md @@ -42,7 +42,7 @@ If nothing is worth capturing, respond without calling any tool. 2. **Before writing**, search for related content so cross-references are accurate: - `wiki_search` with the topic — find related wiki pages to populate `refs`. - `sl_discover` with the concept — if the page defines a metric (revenue, churn, retention, LTV, ARR, MRR, CAC, attribution, etc.), find matching SL sources or measures to populate `sl_refs`. If no matches, pass `sl_refs: []` so future readers know you checked. -3. If updating an existing page, `wiki_read` it first. The read result begins with `[scope: ... | tags: ... | refs: ... | sl_refs: ...]` showing current frontmatter. +3. If updating an existing page, `wiki_read` it first. Use the returned `structured.content` or markdown body as the exact stored text for targeted replacements; current tags, refs, and sl_refs are returned in structured metadata. 4. `wiki_write` to create or update. Prefer merging into an existing page over creating a new one. 5. `wiki_remove` only when a page is truly obsolete — not to replace stale content (update it instead). diff --git a/packages/context/src/wiki/tools/wiki-read.tool.test.ts b/packages/context/src/wiki/tools/wiki-read.tool.test.ts index ccb60abd..b7ad3d1a 100644 --- a/packages/context/src/wiki/tools/wiki-read.tool.test.ts +++ b/packages/context/src/wiki/tools/wiki-read.tool.test.ts @@ -37,4 +37,24 @@ describe('WikiReadTool', () => { expect(result.structured).toMatchObject({ found: true, blockKey: 'staged-page', scope: 'GLOBAL' }); expect(result.markdown).toContain('A page written earlier in the same ingest worktree.'); }); + + it('does not append derived refs to the editable markdown body', async () => { + const rootWikiService = { + readPageForUser: vi.fn().mockResolvedValue({ + pageKey: 'orbit-how-we-work', + scope: 'GLOBAL', + frontmatter: { summary: 'How we work', tags: ['policy'], refs: ['orbit-company-overview'] }, + content: '## How We Work\n\nUse written-first operating norms.', + }), + }; + const pagesRepository = { findPageByKey: vi.fn().mockResolvedValue(null), incrementUsageCount: vi.fn() }; + const tool = new WikiReadTool(rootWikiService as any, pagesRepository as any); + + const result = await tool.call({ key: 'orbit-how-we-work' }, baseContext); + + expect(result.markdown).toBe('## How We Work\n\nUse written-first operating norms.'); + expect(result.markdown).not.toContain('See also'); + expect(result.markdown).not.toContain('[[orbit-company-overview]]'); + expect(result.structured.refs).toEqual(['orbit-company-overview']); + }); }); diff --git a/packages/context/src/wiki/tools/wiki-read.tool.ts b/packages/context/src/wiki/tools/wiki-read.tool.ts index f1ee09f7..228acdf6 100644 --- a/packages/context/src/wiki/tools/wiki-read.tool.ts +++ b/packages/context/src/wiki/tools/wiki-read.tool.ts @@ -34,6 +34,7 @@ export class WikiReadTool extends BaseTool { return ( 'Load the full content of a knowledge block by its key. ' + 'Use this to retrieve detailed rules, preferences, or definitions listed in the . ' + + 'The markdown output is the exact stored page body; use it verbatim for wiki_write replacements. ' + 'Call this when the user query relates to a topic covered by an available knowledge block.' ); } @@ -62,14 +63,8 @@ export class WikiReadTool extends BaseTool { void this.pagesRepository.incrementUsageCount([indexEntry.id]); } - let md = `## ${page.pageKey}\n\n${page.content}`; - const refs = page.frontmatter.refs; - if (refs && refs.length > 0) { - md += `\n\nSee also: ${refs.map((r) => `[[${r}]]`).join(', ')}`; - } - return { - markdown: md, + markdown: page.content, structured: { blockKey: page.pageKey, content: page.content, diff --git a/packages/context/src/wiki/tools/wiki-write.tool.test.ts b/packages/context/src/wiki/tools/wiki-write.tool.test.ts index ec9b1ae9..17c6144a 100644 --- a/packages/context/src/wiki/tools/wiki-write.tool.test.ts +++ b/packages/context/src/wiki/tools/wiki-write.tool.test.ts @@ -6,6 +6,7 @@ import { WikiWriteTool } from './wiki-write.tool.js'; function makeTool(overrides: any = {}) { const wikiService = { readPage: vi.fn().mockResolvedValue(null), + listPageKeys: vi.fn().mockResolvedValue([]), writePage: vi.fn().mockResolvedValue(undefined), syncSinglePage: vi.fn().mockResolvedValue(undefined), ...overrides.wikiService, @@ -245,4 +246,47 @@ describe('WikiWriteTool', () => { summary: 'Monthly paid orders updated', }); }); + + it('rejects frontmatter refs that target missing wiki pages', async () => { + const { tool, wikiService } = makeTool({ + wikiService: { + listPageKeys: vi.fn().mockResolvedValue(['orbit-company-overview']), + }, + }); + + const result = await tool.call( + { + key: 'orbit-how-we-work', + summary: 'Operating norms', + content: '## How We Work', + refs: ['orbit-company-overview', 'orbit-team-lanes-detail'], + } as any, + baseContext, + ); + + expect(result.structured.success).toBe(false); + expect(result.markdown).toMatch(/orbit-team-lanes-detail/); + expect(wikiService.writePage).not.toHaveBeenCalled(); + }); + + it('rejects inline wiki links that target missing wiki pages', async () => { + const { tool, wikiService } = makeTool({ + wikiService: { + listPageKeys: vi.fn().mockResolvedValue(['orbit-company-overview']), + }, + }); + + const result = await tool.call( + { + key: 'orbit-how-we-work', + summary: 'Operating norms', + content: 'See [[orbit-company-overview]] and [[orbit-team-lanes-detail]].', + } as any, + baseContext, + ); + + expect(result.structured.success).toBe(false); + expect(result.markdown).toMatch(/orbit-team-lanes-detail/); + expect(wikiService.writePage).not.toHaveBeenCalled(); + }); }); diff --git a/packages/context/src/wiki/tools/wiki-write.tool.ts b/packages/context/src/wiki/tools/wiki-write.tool.ts index 21f78fd2..871155c0 100644 --- a/packages/context/src/wiki/tools/wiki-write.tool.ts +++ b/packages/context/src/wiki/tools/wiki-write.tool.ts @@ -64,6 +64,71 @@ function normalizeAccidentalEscapedMarkdownNewlines(content: string): string { return content.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\n'); } +function isWikiPageKeyRef(ref: string): boolean { + return /^[a-z0-9][a-z0-9_-]*(?:-[a-z0-9_]+)*$/.test(ref); +} + +function extractInlineWikiRefs(content: string): string[] { + const refs = new Set(); + const re = /\[\[([^\]\n]+)\]\]/g; + for (const match of content.matchAll(re)) { + const target = match[1]?.split('|', 1)[0]?.trim(); + if (target && isWikiPageKeyRef(target)) { + refs.add(target); + } + } + return [...refs].sort(); +} + +async function visibleWikiPageKeys( + wikiService: KnowledgeWikiService, + scope: BlockScope, + scopeId: string | null, +): Promise> { + const keys = new Set(); + if (scope === 'USER') { + for (const key of await wikiService.listPageKeys('GLOBAL', null)) { + keys.add(key); + } + for (const key of await wikiService.listPageKeys('USER', scopeId)) { + keys.add(key); + } + return keys; + } + + for (const key of await wikiService.listPageKeys('GLOBAL', null)) { + keys.add(key); + } + return keys; +} + +async function findMissingWikiRefs(input: { + wikiService: KnowledgeWikiService; + scope: BlockScope; + scopeId: string | null; + pageKey: string; + refs?: string[]; + content: string; +}): Promise { + const candidates = new Set(); + for (const ref of input.refs ?? []) { + if (isWikiPageKeyRef(ref)) { + candidates.add(ref); + } + } + for (const ref of extractInlineWikiRefs(input.content)) { + candidates.add(ref); + } + + if (candidates.size === 0) { + return []; + } + + const available = await visibleWikiPageKeys(input.wikiService, input.scope, input.scopeId); + available.add(input.pageKey); + return [...candidates].filter((ref) => !available.has(ref)).sort(); +} + export class WikiWriteTool extends BaseTool { readonly name = 'wiki_write'; @@ -160,6 +225,23 @@ tags/refs/sl_refs use REPLACE semantics: omit to keep existing on update, [] to finalContent = existing?.content ?? ''; } + const missingRefs = await findMissingWikiRefs({ + wikiService, + scope, + scopeId, + pageKey: input.key, + refs: finalFm.refs, + content: finalContent, + }); + if (missingRefs.length > 0) { + return { + markdown: + `Error: wiki references target missing page(s): ${missingRefs.join(', ')}. ` + + 'Create those pages first, retarget the links, or remove the refs.', + structured: { success: false, key: input.key }, + }; + } + await wikiService.writePage(scope, scopeId, input.key, finalFm, finalContent, SYSTEM_AUTHOR, SYSTEM_EMAIL); if (!skipIndex) { await wikiService.syncSinglePage(scope, scopeId, input.key, finalFm, finalContent);