# How Hooks Are Merged When Converting Multiple Plugin Sources in Compound Engineering

> Learn how hooks are merged when converting multiple plugin sources in compound engineering. Discover how loadHooks and mergeHooks create a unified configuration from various sources.

- Repository: [Every/compound-engineering-plugin](https://github.com/everyinc/compound-engineering-plugin)
- Tags: internals
- Published: 2026-02-16

---

**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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/plugin.json) manifest can declare additional hook files via the `hooks` field as either a single string or an array of paths:

```json
{
  "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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/plugin.json) without external files:

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "*", "hooks": [{ "type": "command", "command": "echo inline" }] }
    ]
  }
}

```

## How the Parser Collects Hook Configurations

The `loadHooks` function in [`src/parsers/claude.ts`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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.

```typescript
// 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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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.

```typescript
// 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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/src/converters/claude-to-opencode.ts) receives this merged configuration and generates a single [`converted-hooks.ts`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/hooks/hooks.json), external files listed in [`plugin.json`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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`](https://github.com/EveryInc/compound-engineering-plugin/blob/main/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.