Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,100 @@ 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<schema, meta>`)

### Final Working Solution

**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 — <description>
- bugfix: Bugfix — <description>
- 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)

1. **`opencode-plugin/package.json`**: Move `zod` from `dependencies` to `peerDependencies` with version `">=4.1.8"`
Expand Down
130 changes: 130 additions & 0 deletions opencode/opencode.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
45 changes: 0 additions & 45 deletions packages/opencode-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
try {
const parameters = output.parameters as {
_zod?: { def?: { shape?: Record<string, { description?: string }> } };
};
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
}
},
};
};

Expand Down
13 changes: 7 additions & 6 deletions packages/opencode-plugin/src/tool-handlers/start-development.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { z } from 'zod';
import {
StartDevelopmentHandler,
generateWorkflowDescription,
buildWorkflowEnum,
type WhatsNextResult,
type ServerContext,
Expand All @@ -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()
Expand Down
Loading