From 17a21d925081fef519faa91d7cfa43cea81f98dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Thu, 7 May 2026 20:17:37 +0200 Subject: [PATCH 1/2] docs: record exhaustive registry bridge research in development plan Documents all 13 investigated approaches to obtaining host's globalRegistry, Zod v4 internals (how descriptions are stored/read), and why the dynamic import approach was chosen as the production-viable solution. --- ...-opencode-plugin-parameter-descriptions.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md b/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md index 4a37cc9f..99d79ee1 100644 --- a/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md +++ b/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md @@ -83,6 +83,91 @@ The `tool.definition` hook bridges registries: - `toJSONSchema({ metadata: pluginRegistry })`: works but can't change OC's hardcoded call - Dynamic import approach: cleanest workable solution for production +### All Investigated Approaches to Get Host's globalRegistry + +The core challenge: from within the plugin, we needed a reference to OpenCode's `globalRegistry` object (a `$ZodRegistry` instance). All approaches tried: + +**1. Static import of `'zod'` (plugin's own copy)** +- `import { globalRegistry } from 'zod'` → resolves to plugin's zod 4.3.6 registry (wrong) +- Even after moving to peerDep, monorepo still has plugin/node_modules/zod@4.3.6 (pulled by another workspace pkg) + +**2. Dynamic import of `'zod'` (production-only fix)** +- `await import('zod')` from plugin code → also resolves to plugin's local zod in dev/monorepo +- In **production** (no local node_modules/zod), this correctly resolves to host's zod ✅ +- Chosen approach — acceptable since dev uses known setup + +**3. `_zod.bag` property on schema** +- Hypothesis: descriptions stored in `_zod.bag` (a per-instance metadata bag) +- Result: `_zod.bag` is always `{}` for described schemas — NOT used for descriptions + +**4. `_zod.parent` trick** +- `fieldSchema._zod.parent = ocDummySchema; ocRegistry.add(ocDummySchema, { description })` +- `$ZodRegistry.get()` inherits from parent: `{ ...parentMeta, ...schemaOwnMeta }` +- Result: `toJSONSchema` treats `_zod.parent` as a `$ref` clone relationship, outputs only `{ description }` with NO type info — field type entirely lost ❌ + +**5. `toJSONSchema({ metadata: pluginRegistry })` option** +- `JSONSchemaGenerator` accepts `params?.metadata` to override the default `globalRegistry` +- `z.toJSONSchema(parameters, { metadata: pluginRegistry })` — WORKS in isolation ✅ +- Problem: OC's `prompt.ts:406` call is hardcoded as `z.toJSONSchema(item.parameters)` — we can't inject the option ❌ + +**6. `$ZodRegistry.prototype.add` temporary monkey-patch** +- Patch the prototype's `add` method; call `parameters.describe("probe")`; `this` inside `add` = the actual registry +- WORKS in isolation ✅ (confirmed in test) +- Problem: to get `$ZodRegistry.prototype`, need a `$ZodRegistry` instance first (circular) +- `$ZodRegistry` is exported from `zod/v4/core`, but that resolves to plugin's zod in dev + +**7. Cross-instance `$ZodRegistry.prototype` patch** +- Plugin's `$ZodRegistry.prototype` vs OC's `$ZodRegistry.prototype` → **different objects** (different module instances) +- Patching plugin's prototype does NOT affect OC's globalRegistry ❌ + +**8. Extract registry via `parameters.describe()` closure** +- `describe()` is a closure: `(desc) => { core.globalRegistry.add(clone, {description}); return clone }` +- `core.globalRegistry` is captured in closure — cannot be extracted from outside +- Tried: wrapping `parameters.describe`, using Proxy, inspecting closure variables — all failed + +**9. `parameters.register(fakeReg, meta)` → `fakeReg.add(schema, meta)`** +- `register(reg, meta)` just calls `reg.add(inst, meta)` with whatever `reg` we pass +- We can intercept our own fake `reg.add` but that doesn't give us the HOST registry +- Useful for writing TO a registry we provide, not for discovering the host's ❌ + +**10. `WeakMap.prototype.set` monkey-patch** +- `$ZodRegistry._map` is a `WeakMap`; `add()` calls `this._map.set(schema, meta)` +- Patching `WeakMap.prototype.set` could intercept the write, but we'd get the WeakMap, not the registry +- Too globally invasive ❌ + +**11. Reconstructing parameters using `_zod.constr`** +- `parameters._zod.constr` is the ZodObject constructor from host's zod +- Can create new schema instances via `new parameters._zod.constr(def)` +- Doesn't help: recreating schemas is complex, and we'd still need host's `describe()` context + +**12. `meta()` method** +- `schema.meta()` (no args) → `core.globalRegistry.get(schema)` — returns metadata object or undefined +- `schema.meta(obj)` → `core.globalRegistry.add(clone, obj); return clone` — same as describe() pattern +- Cannot extract the registry from either form + +**13. `parameters.meta()` as registry sentinel** +- After `parameters.describe("probe")`, the clone is IN host's registry +- `clone.description === "probe"` confirms host registry works +- Still no way to get a reference to the registry from the clone + +### How Descriptions Are Actually Stored (Zod v4 internals) + +- `describe(desc)`: `const cl = inst.clone(); core.globalRegistry.add(cl, { description: desc }); return cl` +- `description` getter: `core.globalRegistry.get(inst)?.description` +- `$ZodRegistry.get(schema)`: checks `schema._zod.parent` for inheritance, then `_map.get(schema)` +- `JSONSchemaGenerator._metadataRegistry`: set from `params?.metadata ?? registries_js_1.globalRegistry` +- During schema emit: `const meta = this.metadataRegistry.get(schema); if (meta) Object.assign(result.schema, meta)` +- Descriptions (and all other metadata) are stored in `$ZodRegistry._map` (a `WeakMap`) + +### Final Working Solution + +Dynamic `import('zod')` in the `tool.definition` hook + `globalRegistry.add()` for each field schema: +- Works because in production (peerDep, no local copy) the import resolves to host's zod module cache +- `output.parameters._zod.def.shape` gives access to the original plugin field schemas +- `fieldSchema.description` reads from plugin's registry cross-instance (it's just a getter calling `core.globalRegistry.get(inst)`) +- `hostRegistry.add(fieldSchema, { description })` makes the SAME schema object findable in host's registry +- All 64 tests pass + ## Implementation Plan (Code Phase Tasks) 1. **`opencode-plugin/package.json`**: Move `zod` from `dependencies` to `peerDependencies` with version `">=4.1.8"` From acf5029a762d23813a506678adc759d83272aa5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Thu, 7 May 2026 20:43:17 +0200 Subject: [PATCH 2/2] fix: embed workflow descriptions in tool description instead of .describe() OpenCode does not propagate Zod parameter .describe() text to the LLM. Confirmed by canary test - descriptions are invisible regardless of zod instance or registry. The tool description IS propagated, so embed the workflow enum choices and their descriptions there directly. Also removes the tool.definition hook and globalRegistry bridging code which were solving the wrong problem. --- ...-opencode-plugin-parameter-descriptions.md | 21 ++- opencode/opencode.json | 130 ++++++++++++++++++ packages/opencode-plugin/src/plugin.ts | 45 ------ .../src/tool-handlers/start-development.ts | 13 +- 4 files changed, 152 insertions(+), 57 deletions(-) create mode 100644 opencode/opencode.json diff --git a/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md b/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md index 99d79ee1..60395732 100644 --- a/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md +++ b/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md @@ -161,12 +161,21 @@ The core challenge: from within the plugin, we needed a reference to OpenCode's ### Final Working Solution -Dynamic `import('zod')` in the `tool.definition` hook + `globalRegistry.add()` for each field schema: -- Works because in production (peerDep, no local copy) the import resolves to host's zod module cache -- `output.parameters._zod.def.shape` gives access to the original plugin field schemas -- `fieldSchema.description` reads from plugin's registry cross-instance (it's just a getter calling `core.globalRegistry.get(inst)`) -- `hostRegistry.add(fieldSchema, { description })` makes the SAME schema object findable in host's registry -- All 64 tests pass +**Embed workflow descriptions in the tool `description` string** (not in `.describe()` on the arg schema). + +All previous approaches (`globalRegistry` bridging, `tool.definition` hook, dynamic zod import) were abandoned after confirming via LLM inspection that OpenCode **never propagates Zod parameter `.describe()` text to the LLM at all** — confirmed by embedding a canary string `DESCRIPTION_TEST_CANARY_12345` via `.describe()` which was invisible to the LLM even after a fresh session. + +The tool `description` field IS propagated. Fix: embed the workflow enum descriptions directly there: +``` +Start a development workflow. + +workflow parameter — available values: + - epcc: EPCC — + - bugfix: Bugfix — + - custom: Use a custom workflow name +``` + +All 286 tests pass. `generateWorkflowDescription()` import removed from `start-development.ts` (no longer needed). `tool.definition` hook removed from `plugin.ts`. `.describe()` calls on args left in place (harmless). ## Implementation Plan (Code Phase Tasks) diff --git a/opencode/opencode.json b/opencode/opencode.json new file mode 100644 index 00000000..125d0714 --- /dev/null +++ b/opencode/opencode.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "@ex-machina/opencode-anthropic-auth@1.8.0", + "/Users/oliverjaegle/projects/privat/codemcp/workflows/packages/opencode-plugin/dist/index.js" + ], + "provider": { + "@ai-sdk/openai-compatible": { + "name": "llama.cpp", + "options": { + "baseURL": "http://flinker:8080/v1" + }, + "models": { + "Qwen3-Coder-30B-A3B-Instruct-UD-Q8_K_XL.gguf": { + "name": "Qwen3-Coder" + }, + "gpt-oss-120b-F16.gguf": { + "name": "gpt-oss-120b" + }, + "devstral2-small": { + "name": "Devstral-Small-2-24B-Instruct-2512-UD-Q8_K_XL.gguf" + } + } + } + }, + "mcp": { + "workflows": { + "command": ["npx", "@codemcp/workflows@latest"], + "type": "local", + "environment": { + "COMMIT_BEHAVIOR": "end" + }, + "enabled": false + }, + "knowledge": { + "command": ["npx", "@codemcp/knowledge@latest"], + "type": "local" + }, + "quiet_shell": { + "command": ["npx", "@codemcp/quiet-shell@latest"], + "type": "local", + "enabled": true + }, + "prompts": { + "command": ["npx", "@codemcp/prompts@latest"], + "type": "local" + }, + "crowd-mcp-local": { + "command": [ + "node", + "/Users/oliverjaegle/projects/privat/mcp-server/crowd/packages/server/dist/index.js" + ], + "environment": { + "CROWD_DEMO_MODE": "true", + "OPERATOR_NAME": "Oliver", + "CROWD_LOG_LEVEL": "WARN" + }, + "type": "local", + "enabled": false + }, + "kinderspiel": { + "command": ["npx", "@codemcp/workflows@latest"], + "type": "local", + "enabled": false, + "environment": { + "VIBE_WORKFLOW_DOMAINS": "children" + } + } + }, + "permission": { + "proceed_to_phase": "ask" + }, + "agent": { + "vibe": { + "description": "Responsible vibe development agent with structured workflows", + "mode": "primary", + "prompt": "IMPORTANT: ALWAYS use whats_next after every user message to determine the next steps.\n Follow the instructions you get from whats_next exactly!\nIMPORTANT: You may also receive errors. Those errors also contain instructions how to proceed. NEVER ignore errors from the mcp tools, but ALWAYS follow the instructions in the errors.\n", + "tools": { "workflows": true }, + "permission": { + "workflows_reset_development": "ask", + "workflows_start_development": "ask", + "workflows_proceed_to_phase": "ask" + } + }, + "crowd": { + "description": "Manages subagents", + "mode": "primary", + "prompt": "You are a development project lead for software development projects. Use spawn_agent tool to employ agents for a specific capability. Assign atomic, well-described tasks.", + "tools": { + "workflows*": false, + "crowd-mcp-local*": true + } + }, + "research": { + "description": "Research and development", + "mode": "primary", + "prompt": "You are a researcher who knows the docs of particular systems and processes. Always search the docs for the questions you are asked and make sure to give precise, but compact answers and edits. If you don't find anything in the docs, respond clearly that you do not know about this topic", + "tools": { + "knowledge*": true + } + }, + "powerpoint": { + "description": "PowerPoint presentation generation", + "mode": "primary", + "prompt": "You are a tool that generates PowerPoint presentations from text prompts. Use the ppt-mcp tools manipulate the slides.", + "tools": { + "ppt-mcp*": true + } + }, + "kinderspiel": { + "description": "Ein Agent, der Kindern beim Entwickeln von Spielen hilft", + "mode": "primary", + "prompt": "You are a friendly, patient, and encouraging AI assistant helping a child (ages 8-12) learn game development.\n\n## 🌍 Language (CRITICAL)\n\n**Detect and match the child's language immediately:**\n\n- All responses, documents, and code comments in their language\n- Never switch languages mid-conversation\n\n## 🎨 Your Language and Tone\n\n- **Simple language**, short sentences\n- **Enthusiastic** like an excited older sibling\n- **Patient** - never rushed\n- **Celebratory** - every small win matters\n- **Supportive** - mistakes are learning opportunities\n\n## 🔧 Tools You MUST Use\n\n### Start the Workflow\n\n```\nstart_development({\n workflow: \"game-beginner\",\n require_reviews: true,\n commit_behaviour: \"phase\"\n})\n```\n\nIf you need to create project docs, link docs if they already exist in .vibe/docs\n\n### After Each User Message\n\n```\nwhats_next({\n context: \"Brief summary of current situation\",\n user_input: \"What the user just said\",\n conversation_summary: \"Overall progress\",\n recent_messages: [...]\n})\n```\n\n**Then follow the instructions you receive exactly!**\nYou're inspiring future creators! 🚀\n", + "tools": { + "kinderspiel*": true, + "crowd-mcp-local*": false, + "workflows": false + } + }, + "workflow": { + "description": "Metal alignment over autonomy", + "prompt": "You follow a defined workflow that helps you be in sync with the user.", + "permission": { + "start_development": "ask", + "reset_development": "ask", + "proceed_to_phase": "ask" + } + } + } +} diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 456087a4..1a1649d1 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -797,51 +797,6 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin ), }; })(), - - /** - * Bridge Zod .describe() descriptions from plugin's registry into host's registry. - * - * Problem: this plugin uses a different zod instance than OpenCode (host). In Zod v4, - * .describe() stores descriptions in a module-level globalRegistry singleton. When - * OpenCode calls z.toJSONSchema(parameters), it reads from its own registry which has - * no entries for plugin schemas — so all parameter descriptions are missing from the - * JSON Schema sent to the LLM. - * - * Solution: dynamically import 'zod' at hook call time. When the plugin is installed - * without its own node_modules/zod (zod is a peerDependency), this import resolves to - * the host's (OpenCode's) zod module — the same instance used when creating - * output.parameters. We then register each field schema's description into the host's - * globalRegistry, making them visible to the subsequent z.toJSONSchema() call. - */ - 'tool.definition': async ( - _input: { toolID: string }, - output: { description: string; parameters: unknown } - ): Promise => { - try { - const parameters = output.parameters as { - _zod?: { def?: { shape?: Record } }; - }; - const shape = parameters?._zod?.def?.shape; - if (!shape) return; - - // Dynamically import zod — in production (installed without local node_modules/zod), - // this resolves to the host's zod instance, sharing the same globalRegistry. - const { globalRegistry: hostRegistry } = await import('zod'); - - for (const [_key, fieldSchema] of Object.entries(shape)) { - const desc = fieldSchema?.description; - if (desc && typeof desc === 'string') { - ( - hostRegistry as { - add(schema: unknown, meta: { description: string }): void; - } - ).add(fieldSchema, { description: desc }); - } - } - } catch { - // Silently ignore — descriptions are a nice-to-have, not critical - } - }, }; }; diff --git a/packages/opencode-plugin/src/tool-handlers/start-development.ts b/packages/opencode-plugin/src/tool-handlers/start-development.ts index 7cbd61a5..482b9523 100644 --- a/packages/opencode-plugin/src/tool-handlers/start-development.ts +++ b/packages/opencode-plugin/src/tool-handlers/start-development.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import { StartDevelopmentHandler, - generateWorkflowDescription, buildWorkflowEnum, type WhatsNextResult, type ServerContext, @@ -26,18 +25,20 @@ export function createStartDevelopmentTool( workflowManager.getAvailableWorkflowsForProject(projectDir); const workflowNames = availableWorkflows.map(w => w.name); - // Build tool description with workflow list + // Build tool description with full workflow details embedded, + // since OpenCode does not propagate Zod .describe() to the LLM. + const workflowLines = availableWorkflows + .map(w => ` - ${w.name}: ${w.displayName} — ${w.description}`) + .join('\n'); const toolDescription = workflowNames.length > 0 - ? `Start a development workflow. Available: ${workflowNames.join(', ')}` + ? `Start a development workflow.\n\nworkflow parameter — available values:\n${workflowLines}\n - custom: Use a custom workflow name` : 'Start a development workflow (no workflows available - check WORKFLOW_DOMAINS)'; return tool({ description: toolDescription, args: { - workflow: z - .enum(buildWorkflowEnum(workflowNames)) - .describe(generateWorkflowDescription(availableWorkflows)), + workflow: z.enum(buildWorkflowEnum(workflowNames)), require_reviews: z .boolean() .optional()