How Hooks Are Merged When Converting Multiple Plugin Sources in Compound Engineering
When converting Claude-style plugins, the loadHooks function collects hook definitions from default files, explicit paths, and inline objects, then mergeHooks concatenates their matcher arrays in source order to produce a single unified hook configuration.
The EveryInc/compound-engineering-plugin CLI handles complex plugin structures that define hooks across multiple files and inline declarations. Understanding how these sources are combined ensures predictable behavior when converting Claude plugins to the Opencode format.
The Three Sources of Plugin Hooks
Claude-style plugins can supply hook definitions through three distinct mechanisms that the parser discovers in a specific priority order.
Default Hooks File
Every plugin may include a standard hooks definition at hooks/hooks.json relative to the plugin root. The parser checks for this file first and automatically loads it if present.
Explicit Hook Paths in plugin.json
The plugin.json manifest can declare additional hook files via the hooks field as either a single string or an array of paths:
{
"hooks": [
"hooks/additional.json",
"hooks/security-hooks.json"
]
}
These paths are resolved relative to the plugin root and loaded after the default file but before any inline definitions.
Inline Hook Objects
The hooks field may also contain the hook definition object directly, allowing complete hook configurations to live inside plugin.json without external files:
{
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo inline" }] }
]
}
}
How the Parser Collects Hook Configurations
The loadHooks function in src/parsers/claude.ts orchestrates the discovery process. It maintains a hookConfigs array and pushes each discovered source in the order encountered: default file first, then explicit paths, then inline objects.
// src/parsers/claude.ts – loadHooks
async function loadHooks(root: string, hooksField?: ClaudeManifest["hooks"]): Promise<ClaudeHooks | undefined> {
const hookConfigs: ClaudeHooks[] = [];
// 1️⃣ default file
const defaultPath = path.join(root, "hooks", "hooks.json");
if (await pathExists(defaultPath)) {
hookConfigs.push(await readJson<ClaudeHooks>(defaultPath));
}
// 2️⃣ explicit external files
if (hooksField) {
if (typeof hooksField === "string" || Array.isArray(hooksField)) {
const hookPaths = toPathList(hooksField);
for (const hookPath of hookPaths) {
const resolved = resolveWithinRoot(root, hookPath, "hooks path");
if (await pathExists(resolved)) {
hookConfigs.push(await readJson<ClaudeHooks>(resolved));
}
}
} else {
// 3️⃣ inline object
hookConfigs.push(hooksField);
}
}
if (hookConfigs.length === 0) return undefined;
return mergeHooks(hookConfigs); // ← combine them
}
If no hook sources are found, the function returns undefined. Otherwise, it delegates to mergeHooks to unify the collected configurations.
The mergeHooks Algorithm
The mergeHooks function in src/parsers/claude.ts performs a shallow merge that concatenates matcher arrays while preserving source order. It iterates through each ClaudeHooks object in the input array and appends matchers to the corresponding event key in the merged result.
// src/parsers/claude.ts – mergeHooks
function mergeHooks(hooksList: ClaudeHooks[]): ClaudeHooks {
const merged: ClaudeHooks = { hooks: {} };
for (const hooks of hooksList) {
for (const [event, matchers] of Object.entries(hooks.hooks)) {
if (!merged.hooks[event]) {
merged.hooks[event] = [];
}
merged.hooks[event].push(...matchers); // concatenate
}
}
return merged;
}
This algorithm ensures that matchers from the default file appear first, followed by matchers from explicitly listed files in their declared order, and finally matchers from inline definitions. The order is significant because hooks are typically evaluated sequentially during execution.
Conversion to Opencode Format
After merging, the unified ClaudeHooks object is attached to the parsed plugin structure. During conversion, the convertHooks function in src/converters/claude-to-opencode.ts receives this merged configuration and generates a single converted-hooks.ts file containing all hook implementations.
The converter iterates over the merged map and emits TypeScript code that implements each matcher in the preserved order, ensuring that the behavior defined across multiple source files is replicated exactly in the target Opencode plugin format.
Summary
- Three sources: Hooks can be defined in
hooks/hooks.json, external files listed inplugin.json, or inline objects within the manifest. - Collection order: The parser loads default files first, then explicit paths, then inline definitions.
- Merge strategy:
mergeHooksconcatenates matcher arrays per event, preserving source order to maintain execution priority. - Single output: The merged result is converted into one
converted-hooks.tsfile containing all combined logic.
Frequently Asked Questions
What happens if the same hook event is defined in multiple files?
When the same event (such as PreToolUse) appears in multiple hook sources, mergeHooks concatenates their matcher arrays. Matchers from the default hooks.json appear first, followed by matchers from explicitly listed files in their declared order, and finally matchers from inline definitions. All matchers are preserved and executed in this sequence.
Does the merge process deduplicate identical matchers?
No, the mergeHooks function does not deduplicate matchers. It performs a simple concatenation using the spread operator (...matchers), meaning identical matcher objects defined in different sources will appear multiple times in the final array. This behavior ensures that intentionally repeated hooks are preserved but requires manual cleanup if deduplication is desired.
Can I override a default hook by defining it in plugin.json?
You cannot override or remove hooks defined in the default hooks/hooks.json file through subsequent definitions. Because the merge process concatenates rather than replaces, any hooks in the default file will always execute first. To effectively "override" behavior, you must either modify the default file directly or ensure your subsequent hooks handle the logic conditionally.
What file is generated from the merged hooks?
The conversion process generates a single file named converted-hooks.ts in the output directory. This TypeScript file contains the implementation of all merged hook matchers, exported as a module that the Opencode plugin system can import and execute. The file reflects the concatenated order of matchers as established by the merge process.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →