diff --git a/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap b/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap index 5ac769c85..5a1b39086 100644 --- a/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap +++ b/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap @@ -3,11 +3,15 @@ exports[`D0 parity spike — additive & gated tool registration > pre-D0 production design tool DEFINITIONS are unchanged (snapshot) > pre-d0-design-tool-definitions 1`] = ` [ { - "description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide), elements (N-tool element-tool family reference — decision tree, PREFER/FALLBACK rules, composition pattern; the section itself enumerates the current tools). Omit section for the full prompt.", + "description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy — reads the active document's design.md when present), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide), elements (N-tool element-tool family reference), design-md (raw style policy derived from the active document's design.md, or a "no design.md" notice). Omit section for the full prompt.", "inputSchema": { "properties": { + "filePath": { + "description": "Path to .op file, or omit to use the live canvas. The prompt's 'style' / 'design-md' sections are derived from THIS document's design.md so the reply never leaks another file's design system.", + "type": "string", + }, "section": { - "description": "Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference.", + "description": "Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference; "design-md" for the active document's design system.", "enum": [ "all", "schema", @@ -20,6 +24,7 @@ exports[`D0 parity spike — additive & gated tool registration > pre-D0 product "guidelines", "planning", "elements", + "design-md", ], "type": "string", }, diff --git a/packages/pen-mcp/src/routes/design-routes.ts b/packages/pen-mcp/src/routes/design-routes.ts index 3023ca68c..b88defcea 100644 --- a/packages/pen-mcp/src/routes/design-routes.ts +++ b/packages/pen-mcp/src/routes/design-routes.ts @@ -1,5 +1,10 @@ import { handleBatchDesign } from '../tools/batch-design'; -import { buildDesignPrompt, listPromptSections } from '../tools/design-prompt'; +import { + buildDesignPrompt, + listPromptSections, + designMdSpecToPromptPolicy, +} from '../tools/design-prompt'; +import { openDocument, resolveDocPath } from '../document-manager'; import { handleDesignSkeleton } from '../tools/design-skeleton'; import { handleDesignContent } from '../tools/design-content'; import { handleDesignRefine } from '../tools/design-refine'; @@ -22,10 +27,10 @@ const CORE_DESIGN_TOOL_DEFINITIONS = [ description: 'Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. ' + 'Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), ' + - 'style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), ' + - 'planning (layered workflow guide), elements (N-tool element-tool family reference — decision tree, ' + - 'PREFER/FALLBACK rules, composition pattern; the section itself enumerates the current tools). ' + - 'Omit section for the full prompt.', + "style (visual style policy — reads the active document's design.md when present), icons (icon names), " + + 'examples (design examples), guidelines (design tips), planning (layered workflow guide), ' + + 'elements (N-tool element-tool family reference), design-md (raw style policy derived from the active ' + + 'document\'s design.md, or a "no design.md" notice). Omit section for the full prompt.', inputSchema: { type: 'object' as const, properties: { @@ -43,9 +48,15 @@ const CORE_DESIGN_TOOL_DEFINITIONS = [ 'guidelines', 'planning', 'elements', + 'design-md', ], description: - 'Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference.', + 'Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference; "design-md" for the active document\'s design system.', + }, + filePath: { + type: 'string', + description: + "Path to .op file, or omit to use the live canvas. The prompt's 'style' / 'design-md' sections are derived from THIS document's design.md so the reply never leaks another file's design system.", }, }, required: [], @@ -118,16 +129,26 @@ export async function handleDesignToolCall( ): Promise { const a = args as any; // eslint-disable-line @typescript-eslint/no-explicit-any switch (name) { - case 'get_design_prompt': + case 'get_design_prompt': { + // Derive design.md policy from THIS document so the prompt cannot + // leak another document's design system (per-document isolation). + let designMdPolicy: string | null = null; + try { + const doc = await openDocument(resolveDocPath(a.filePath as string | undefined)); + designMdPolicy = designMdSpecToPromptPolicy(doc.designMd); + } catch { + // live canvas / file unavailable — prompt still works without policy + } return JSON.stringify( { section: (a.section as string | undefined) ?? 'all', availableSections: listPromptSections(), - designPrompt: buildDesignPrompt(a.section as string | undefined), + designPrompt: buildDesignPrompt(a.section as string | undefined, designMdPolicy), }, null, 2, ); + } case 'batch_design': return JSON.stringify(await handleBatchDesign(a), null, 2); case 'design_skeleton': diff --git a/packages/pen-mcp/src/tools/design-md.ts b/packages/pen-mcp/src/tools/design-md.ts index 13e078b24..81d87ed2f 100644 --- a/packages/pen-mcp/src/tools/design-md.ts +++ b/packages/pen-mcp/src/tools/design-md.ts @@ -5,7 +5,6 @@ import { extractDesignMdFromDocument, } from '../utils/design-md-parser'; import type { DesignMdSpec } from '@zseven-w/pen-types'; -import { setDesignMdForPrompt } from './design-prompt'; /** * design.md is now stored on the PenDocument (`doc.designMd`). It travels @@ -46,7 +45,6 @@ export async function handleGetDesignMd( const doc = await openDocument(filePath); if (doc.designMd) { - setDesignMdForPrompt(doc.designMd); return { hasDesignMd: true, spec: doc.designMd, @@ -85,7 +83,6 @@ export async function handleSetDesignMd( doc.designMd = spec; await saveDocument(filePath, doc); - setDesignMdForPrompt(spec); return { success: true, spec }; } diff --git a/packages/pen-mcp/src/tools/design-prompt.ts b/packages/pen-mcp/src/tools/design-prompt.ts index 3801c7a5e..b513fe5f9 100644 --- a/packages/pen-mcp/src/tools/design-prompt.ts +++ b/packages/pen-mcp/src/tools/design-prompt.ts @@ -267,7 +267,7 @@ const SECTION_MAP: Record string> = { guidelines: () => DESIGN_GUIDELINES, planning: () => PLANNING_GUIDE, elements: () => getSkillContent('elements'), - 'design-md': () => _designMdContent ?? 'No design.md loaded in the current document.', + 'design-md': () => 'No design.md loaded in the current document.', copywriting: () => getSkillContent('copywriting'), overflow: () => getSkillContent('overflow'), cjk: () => getSkillContent('cjk'), @@ -285,17 +285,13 @@ const SECTION_MAP: Record string> = { 'codegen-react-native': () => getSkillContent('codegen-react-native'), }; -// Design.md content injected via setDesignMdForPrompt() -let _designMdContent: string | null = null; - -/** Set the design.md content to be returned by the 'design-md' section. */ -export function setDesignMdForPrompt(spec: DesignMdSpec | undefined): void { - _designMdContent = spec ? buildDesignMdStylePolicy(spec) : null; -} - -/** Get the design.md style policy, or null if not loaded. */ -export function getDesignMdForPrompt(): string | null { - return _designMdContent; +/** + * Derive the design.md style policy string for a spec, or null if no spec. + * Stateless — callers thread the result through `buildDesignPrompt` so + * there is no process-level global that could leak between documents. + */ +export function designMdSpecToPromptPolicy(spec: DesignMdSpec | null | undefined): string | null { + return spec ? buildDesignMdStylePolicy(spec) : null; } // --------------------------------------------------------------------------- @@ -308,12 +304,21 @@ export function getDesignMdForPrompt(): string | null { * When `section` is provided, returns only that focused subset of design * knowledge. This allows external LLMs to load context incrementally * instead of consuming the full prompt at once. + * + * `designMdPolicy` (caller-supplied, derived from the active document's + * `doc.designMd`) replaces the generic "style" section content and fills + * the "design-md" section. Pass null when the caller's document has no + * design.md — the prompt falls back to the generic style rules. */ -export function buildDesignPrompt(section?: string): string { +export function buildDesignPrompt(section?: string, designMdPolicy?: string | null): string { if (section) { - // When design-md is loaded, 'style' section returns it instead of default - if (section === 'style' && _designMdContent) { - return `DESIGN SYSTEM (from design.md):\n${_designMdContent}`; + if (designMdPolicy) { + if (section === 'style') { + return `DESIGN SYSTEM (from design.md):\n${designMdPolicy}`; + } + if (section === 'design-md') { + return designMdPolicy; + } } if (section in SECTION_MAP) { return SECTION_MAP[section as PromptSection]();