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 in plugin.json, or inline objects within the manifest.
  • Collection order: The parser loads default files first, then explicit paths, then inline definitions.
  • Merge strategy: mergeHooks concatenates matcher arrays per event, preserving source order to maintain execution priority.
  • Single output: The merged result is converted into one converted-hooks.ts file 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →