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
4 changes: 2 additions & 2 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Multiple files can set `root: true` for the same target in project and global mo

`.rulesync/hooks.jsonc` is the recommended source path and accepts comments and trailing commas. The legacy `.rulesync/hooks.json` path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.

Hermes Agent accepts native snake-case events under `hermesagent.hooks`: `pre_tool_call`, `post_tool_call`, `transform_terminal_output`, `transform_tool_result`, `transform_llm_output`, `pre_llm_call`, `post_llm_call`, `pre_verify`, `pre_api_request`, `post_api_request`, `api_request_error`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `pre_approval_request`, `post_approval_response`, `kanban_task_claimed`, `kanban_task_completed`, and `kanban_task_blocked`. Rulesync maps shared canonical events first, applies canonical keys from `hermesagent.hooks` next, then applies exact native keys last. An exact native key therefore wins when both forms resolve to the same Hermes event. Native-only events remain under `hermesagent.hooks` on import instead of leaking into other targets.
Hermes Agent accepts native snake-case events under `hermesagent.hooks`: `pre_tool_call`, `post_tool_call`, `transform_terminal_output`, `transform_tool_result`, `transform_llm_output`, `pre_llm_call`, `post_llm_call`, `pre_verify`, `pre_api_request`, `post_api_request`, `api_request_error`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `pre_approval_request`, `post_approval_response`, `kanban_task_claimed`, `kanban_task_completed`, and `kanban_task_blocked`. Rulesync maps shared canonical events first, applies canonical keys from `hermesagent.hooks` next, then applies exact native keys last. An exact native key therefore wins when both forms resolve to the same Hermes event. Native-only events remain under `hermesagent.hooks` on import instead of leaking into other targets. Rulesync owns the event keys inside the `hooks:` mapping of `config.yaml`, but not the mapping itself: Hermes v0.20.0 nests the [outbound webhook registry](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) under the same key as `hooks.outbound`, so any key there that is not a Hermes hook event is carried over from the existing file untouched. Rulesync neither authors nor imports `outbound`, since it is a list of webhook targets rather than a hook event; it only makes sure a regenerate leaves it alone. An event key Rulesync did write, including one under an undocumented event name supplied through `hermesagent.hooks`, is still retracted when it disappears from the source.

Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Qwen Code, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`) — both share one event surface, in which `preToolUse`/`postToolUse` become named `tool.execute.before`/`tool.execute.after` hooks, `preCompact` becomes the named `experimental.session.compacting` hook (which receives `(input, output)` and exposes nothing to match on, so a `matcher` on it is dropped), `beforeShellExecution`/`afterShellExecution` also land in those named `tool.execute.*` hooks with an implicit `input.tool === "bash"` gate — OpenCode has no shell-execution lifecycle event (`command.executed`, which earlier Rulesync versions mapped `afterShellExecution` to, is a _slash-command_ event, so the hook never fired on shell commands; regenerate to fix), and matchers on the shell events are dropped with a warning since the named hooks expose no command text, and the rest are `event.type` dispatches — `sessionStart` → `session.created`, `stop` → `session.idle`, `afterFileEdit` → `file.edited`, `permissionRequest` → `permission.asked`, `postCompact` → `session.compacted`, `afterError` → `session.error`, `fileChanged` → `file.watcher.updated`; Amp hooks are emitted as a TypeScript plugin (`.amp/plugins/rulesync-hooks.ts`, or `~/.config/amp/plugins/rulesync-hooks.ts` in global mode) using `session.start`, `tool.call`, `tool.result`, `agent.start`, and `agent.end`; Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (`.pi/extensions/rulesync-hooks.ts`, or `~/.pi/agent/extensions/rulesync-hooks.ts` in global mode) that subscribes to Pi's snake_case extension events (`sessionStart` → `session_start`, `stop` → `agent_end`, `preToolUse` → `tool_call` with the matcher tested as a regex against the tool name, `preCompact` → `session_before_compact`, `postCompact` → `session_compact`, `postModelInvocation` → `message_end` gated on assistant messages so it runs once per finalized model response) and observes events only — command hooks run but cannot block or mutate Pi events; Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli gets the Hooks v2 PascalCase `HookEvent` names (e.g. `SessionStart`, `PostToolUseFailure`) in a `{ "hooks": { "<Event>": [{ "matcher": …, "hooks": [{ "type": "command", … }] }] } }` document — this requires deepagents-code 0.1.52+, the release where Hooks v2 became generally available (the legacy flat list is removed upstream on 2026-09-01; Rulesync still imports the legacy format but no longer writes it); `kiro-cli` and `kiro-ide` emit hooks into the standalone `.kiro/hooks/rulesync.json` with PascalCase triggers, while the deprecated `kiro` alias still writes them into `.kiro/agents/default.json` using the older event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's).

Expand Down Expand Up @@ -932,7 +932,7 @@ The `type` (and the equivalent `transport`) field accepts `local`, `stdio`, `sse

> **Kimi Code note:** MCP servers are written to `.kimi-code/mcp.json` (project) and `~/.kimi-code/mcp.json` (global). Kimi Code supports stdio, HTTP, and SSE plus `env`, `cwd`, `headers`, `bearerTokenEnvVar`, `enabled`, `startupTimeoutMs`, `toolTimeoutMs`, `enabledTools`, and `disabledTools`; Rulesync preserves the canonical fields that Kimi accepts. Canonical `local` maps to stdio and `streamable-http` maps to HTTP. WebSocket servers are skipped with a warning because Kimi has no WebSocket transport. A `kimi-code` block may also carry `startupTimeoutMs` / `toolTimeoutMs`, which are **not** per-server: they become Kimi's `[mcp] startup_timeout_ms` / `tool_timeout_ms` defaults in the shared global `~/.kimi-code/config.toml`, applying to every MCP server including ones Rulesync did not write (a per-server value in `mcp.json` still wins). Global scope only, since `config.toml` has no project counterpart, and merged in place so the `hooks` and `permission` sections of the same file survive. The merge is per key: authoring only one of the two timeouts leaves a hand-written sibling alone, and dropping the override entirely leaves the section as it stands rather than deleting it — remove the keys from `config.toml` by hand if you want them gone. See the [Kimi Code MCP docs](https://moonshotai.github.io/kimi-code/en/customization/mcp.html) and [config-files reference](https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#mcp).

> **Hermes Agent note:** Hermes MCP servers live under `mcp_servers` in the shared `~/.hermes/config.yaml`. Rulesync preserves OAuth fields (`redirect_uri`, `redirect_host`, `redirect_port`, `client_id`, `client_secret`, and `scopes`) plus `idle_timeout_seconds`, `max_lifetime_seconds`, `ssl_verify` (`true`/`false` or a PEM CA-bundle path), `skip_preflight`, and the `sampling` mapping (carried as an opaque object so new sub-keys keep working). On import, portable server fields remain in shared `mcpServers`; Hermes-only fields are isolated in the full `hermesagent.mcpServers.<name>` replacement block so they cannot leak to other targets.
> **Hermes Agent note:** Hermes MCP servers live under `mcp_servers` in the shared `~/.hermes/config.yaml`. Rulesync preserves OAuth fields (`redirect_uri`, `redirect_host`, `redirect_port`, `client_id`, `client_secret`, and `scopes`) plus `idle_timeout_seconds`, `max_lifetime_seconds`, `ssl_verify` (`true`/`false` or a PEM CA-bundle path), `skip_preflight`, `keepalive_interval` (liveness ping cadence in seconds), and the `sampling` and `elicitation` mappings (carried as opaque objects so new sub-keys keep working). A canonical `sse` server is written with Hermes's own `transport: sse` (v0.20.0) and imports back as `type: "sse"`; without it Hermes connects to a `url` server over Streamable HTTP, so the transport would silently change. Streamable HTTP is Hermes's default and stays implicit. On import, portable server fields remain in shared `mcpServers`; Hermes-only fields are isolated in the full `hermesagent.mcpServers.<name>` replacement block so they cannot leak to other targets.

> **Devin note:** Since Devin v3000.3 (the Local 3.6 release), MCP servers live in a dedicated `mcpServers`-keyed file: `.devin/mcp_config.json` (project) and `~/.config/devin/mcp_config.json` (global, via `--global`). The file is MCP-only and rulesync-owned (rewritten whole, deletable), unlike the shared `.devin/config.json` that permissions and hooks keep patching in place. Rulesync no longer writes the legacy `config.json` `mcpServers` key — Devin auto-migrates it away on startup, so re-seeding it would fight the migration — but import still falls back to that key when no `mcp_config.json` exists, so pre-v3000.3 repos migrate cleanly. The gitignored personal override `.devin/mcp_config.local.json` is never read or written (it is covered by the derived `.gitignore`). See the [Devin MCP configuration docs](https://docs.devin.ai/cli/extensibility/mcp/configuration).

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ Rulesync supports both **generation** and **import** for All of the major AI cod

## Hermes Agent compatibility

The `hermesagent` target is validated against Hermes Agent v0.19.0 (release
`v2026.7.20`). The supported contract covers project rules, ignore patterns,
The `hermesagent` target is validated against Hermes Agent v0.20.0 (release
`v2026.8.3`). The supported contract covers project rules, ignore patterns,
subagents, and checks, plus global MCP servers, commands, subagents, skills,
hooks, and permissions. Generation, `--check`, and import round-trips are
covered for both advertised scopes.
Expand Down
91 changes: 91 additions & 0 deletions src/features/hooks/hermesagent-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,100 @@ hooks:
pre_tool_call: [{ command: "pnpm lint" }],
});
});

it("preserves the hooks.outbound webhook registry (#2414)", async () => {
const hooks = await HermesagentHooks.fromRulesyncHooks({
outputRoot: ".",
rulesyncHooks: rulesyncHooksFrom({
version: 1,
hooks: { preToolUse: [{ command: "pnpm lint" }] },
}),
});

hooks.setFileContent(`hooks:
outbound:
- name: ci-notify
url: https://ci.example.com/hermes-events
events: [on_session_end]
secret_env: HERMES_OUTBOUND_WEBHOOK_SECRET
timeout: 10
post_tool_call:
- command: stale.sh
`);

const config = parseSharedConfig({ format: "yaml", fileContent: hooks.getFileContent() });
// `outbound` is a webhook registry, not a hook event: it has no rulesync
// spelling, so replacing the whole mapping destroyed it silently.
expect(config.hooks).toEqual({
outbound: [
{
name: "ci-notify",
url: "https://ci.example.com/hermes-events",
events: ["on_session_end"],
secret_env: "HERMES_OUTBOUND_WEBHOOK_SECRET",
timeout: 10,
},
],
pre_tool_call: [{ command: "pnpm lint" }],
});
});

it("still retracts a native event key that the rulesync source no longer declares", async () => {
const hooks = await HermesagentHooks.fromRulesyncHooks({
outputRoot: ".",
rulesyncHooks: rulesyncHooksFrom({ version: 1, hooks: {} }),
});

hooks.setFileContent(`hooks:
outbound:
- url: https://ci.example.com/hermes-events
pre_tool_call:
- command: stale.sh
`);

const config = parseSharedConfig({ format: "yaml", fileContent: hooks.getFileContent() });
expect(config.hooks).toEqual({
outbound: [{ url: "https://ci.example.com/hermes-events" }],
});
});

it("retracts an undocumented event key it wrote through the override block", async () => {
// Written by an earlier generate from `hermesagent.hooks`, which emits
// event names Hermes does not document yet. Those are rulesync's, so
// removing them from the source has to remove them from the file — the
// sibling-preserving merge must not mistake them for a registry.
const hooks = await HermesagentHooks.fromRulesyncHooks({
outputRoot: ".",
rulesyncHooks: rulesyncHooksFrom({ version: 1, hooks: {} }),
});

hooks.setFileContent(`hooks:
on_context_compact:
- command: stale.sh
`);

const config = parseSharedConfig({ format: "yaml", fileContent: hooks.getFileContent() });
expect(config.hooks).toEqual({});
});
});

describe("toRulesyncHooks", () => {
it("does not import the outbound webhook registry as a hook (#2414)", () => {
const hooks = new HermesagentHooks({
outputRoot: ".",
fileContent: `hooks:
outbound:
- name: ci-notify
url: https://ci.example.com/hermes-events
events: [on_session_end]
`,
});

const imported = JSON.parse(hooks.toRulesyncHooks().getFileContent());
expect(imported.hooks).toEqual({});
expect(imported.hermesagent).toBeUndefined();
});

it("round-trips native VALID_HOOKS event keys back to canonical event names", () => {
const hooks = new HermesagentHooks({
outputRoot: ".",
Expand Down
76 changes: 74 additions & 2 deletions src/features/hooks/hermesagent-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../../utils/hermesagent.js";
import type { Logger } from "../../utils/logger.js";
import { PROTOTYPE_POLLUTION_KEYS } from "../../utils/prototype-pollution.js";
import { isPlainObject } from "../../utils/type-guards.js";
import {
applySharedConfigPatch,
parseSharedConfig,
Expand Down Expand Up @@ -60,6 +61,32 @@ const HERMESAGENT_MATCHER_EVENTS: ReadonlySet<string> = new Set([
const HERMESAGENT_CANONICAL_EVENTS: ReadonlySet<string> = new Set(HERMESAGENT_HOOK_EVENTS);
const HERMESAGENT_NATIVE_EVENTS: ReadonlySet<string> = new Set(HERMESAGENT_NATIVE_HOOK_EVENTS);

/**
* Whether an entry of the `hooks:` mapping is a hook-event list rather than one
* of its non-event siblings.
*
* The mapping is not all events: Hermes v0.20.0 nests the outbound webhook
* registry there as `hooks.outbound`, a list of targets (`name`, `url`,
* `events`, `secret_env`, `matcher`, `timeout`) that rulesync neither authors
* nor imports. A documented native event is an event whatever its value; for
* anything else the value decides, because rulesync also emits *undocumented*
* event names supplied through the `hermesagent.hooks` override (forward
* compatibility), and those must stay retractable. Everything rulesync writes
* is a non-empty list of entries carrying a string `command`, which no registry
* entry has — `outbound` entries carry `url`/`events` instead.
*
* Both directions ask this one question, so import and generate cannot drift.
* @see https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
*/
function isHermesHookEventEntry(key: string, value: unknown): boolean {
if (HERMESAGENT_NATIVE_EVENTS.has(key)) return true;
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((entry) => isPlainObject(entry) && typeof entry.command === "string")
);
}

/**
* Convert the canonical hooks config into Hermes's native
* `hooks: { <event>: [{ matcher?, command, timeout? }] }` shape.
Expand Down Expand Up @@ -184,7 +211,7 @@ function canonicalToHermesHooks({
}
if (!HERMESAGENT_NATIVE_EVENTS.has(nativeEvent)) {
logger?.warn(
`Hermes hook event "${nativeEvent}" is not documented by Hermes Agent v0.19.0; preserving it for forward compatibility.`,
`Hermes hook event "${nativeEvent}" is not documented by Hermes Agent v0.20.0; preserving it for forward compatibility.`,
);
}
setHermesHookEntries({ result, event: nativeEvent, definitions, logger });
Expand All @@ -210,6 +237,12 @@ function hermesHooksToCanonical(hooks: unknown): HooksConfig["hooks"] {
if (PROTOTYPE_POLLUTION_KEYS.has(nativeEvent) || !Array.isArray(entries)) {
continue;
}
// A non-event sibling such as the `outbound` webhook registry is not a hook
// and must not be read as one, or a regenerate would write it back in the
// wrong shape.
if (!isHermesHookEventEntry(nativeEvent, entries)) {
continue;
}
const rulesyncEvent = HERMESAGENT_TO_CANONICAL_EVENT_NAMES[nativeEvent] ?? nativeEvent;

const defs: HookDefinition[] = [];
Expand Down Expand Up @@ -243,6 +276,38 @@ function hermesHooksToCanonical(hooks: unknown): HooksConfig["hooks"] {
return canonical;
}

/**
* Recompute the `hooks:` mapping that is written back to `config.yaml`.
*
* rulesync owns the hook events inside that mapping, but not the mapping
* itself: Hermes v0.20.0 nests the outbound webhook registry under the same key
* as `hooks.outbound`, and it is a list of webhook targets rather than a hook
* event, so it has no rulesync spelling and no migration path. Replacing the
* whole mapping destroyed it on every generate. Every key that is not an event
* ({@link isHermesHookEventEntry}) is therefore carried over from the existing
* file, while event keys are replaced wholesale so a hook deleted from the
* rulesync source is retracted — including one written under an undocumented
* event name through the `hermesagent.hooks` override.
* @see https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
*/
function mergeHermesHooksBlock({
existingHooks,
generatedHooks,
}: {
existingHooks: unknown;
generatedHooks: unknown;
}): Record<string, unknown> {
const preserved: Record<string, unknown> = {};
if (isPlainObject(existingHooks)) {
for (const [key, value] of Object.entries(existingHooks)) {
if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
if (isHermesHookEventEntry(key, value)) continue;
preserved[key] = value;
}
}
return { ...preserved, ...(isPlainObject(generatedHooks) ? generatedHooks : {}) };
}

/**
* Hermes Agent shell hooks.
*
Expand Down Expand Up @@ -323,11 +388,18 @@ export class HermesagentHooks extends ToolHooks {
}

setFileContent(fileContent: string): void {
const existing = parseSharedConfig({ format: "yaml", fileContent });
const generated = parseSharedConfig({ format: "yaml", fileContent: this.fileContent });
this.fileContent = applySharedConfigPatch({
fileKey: getHermesagentConfigSharedFileKey({ global: this.global }),
feature: "hooks",
existingContent: fileContent,
patch: parseSharedConfig({ format: "yaml", fileContent: this.fileContent }),
patch: {
hooks: mergeHermesHooksBlock({
existingHooks: existing.hooks,
generatedHooks: generated.hooks,
}),
},
});
}

Expand Down
Loading
Loading