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

> **Kimi Code note:** Kimi Code reads `.kimi-code/AGENTS.md` at project scope and `~/.kimi-code/AGENTS.md` at user scope. When `KIMI_CODE_HOME` is set, Rulesync follows Kimi and resolves every global Kimi-specific file (`AGENTS.md`, `mcp.json`, `config.toml`, `skills/`, and `agents/`) under that custom data root; the shared `~/.agents/skills/` and `~/.agents/agents/` discovery roots remain under the user's real home directory. Because Kimi has no dedicated directory for topic-based instruction files, Rulesync folds every non-root rule body into that single file. See the [Kimi Code agents and instruction-files docs](https://moonshotai.github.io/kimi-code/en/customization/agents.html) and [environment-variable docs](https://moonshotai.github.io/kimi-code/en/configuration/env-vars.html).

> **OpenCode note:** OpenCode writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.opencode/memories/*.md`. Because OpenCode auto-loads only the root `AGENTS.md` plus files explicitly listed in the `instructions` array of `opencode.json` (it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `opencode.json`/`opencode.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved, and the `instructions` list is deduped and sorted.
> **OpenCode note:** OpenCode writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.opencode/memories/*.md`. Because OpenCode auto-loads only the root `AGENTS.md` plus files explicitly listed in the `instructions` array of `opencode.json` (it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `opencode.json`/`opencode.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). The same applies in **global** mode (via `--global`): OpenCode reads `instructions` from the global `~/.config/opencode/opencode.json` too, so global non-root rules are written to `~/.config/opencode/memories/*.md` and registered there (entries relative to the config file's directory, e.g. `memories/style.md`) instead of being dropped. This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved, and the `instructions` list is deduped and sorted.

> **Qwen Code note:** Qwen Code writes the root rule to the auto-loaded `QWEN.md` (project) / `~/.qwen/QWEN.md` (global, via `--global`) as plain Markdown, and non-root rules to its path-based context-rule directory `.qwen/rules/` (project) / `~/.qwen/rules/` (global). Each non-root rule is a Markdown file with optional YAML frontmatter: Rulesync maps `globs` ⇄ Qwen's `paths` (a picomatch glob array) and `description` ⇄ `description`. A rule **with** specific `paths` is _conditional_ — Qwen lazily injects it only when the model touches a matching file — while a rule **without** `paths` (empty or wildcard `**/*`/`*` globs) is a _baseline_ rule loaded at session start and is written as plain Markdown with no frontmatter block. The `.qwen/rules/` directory supersedes the legacy `.qwen/memories/` import surface, so each rule is emitted to exactly one location; the root `QWEN.md` is unchanged. A `localRoot: true` rule is emitted to `.qwen/QWEN.local.md` (project scope only) — Qwen Code v0.16.2's personal project context file, loaded after the shared `QWEN.md` so it can override team instructions; the file is covered by the derived `.gitignore` since Qwen Code does not gitignore it for you. See the [Qwen Code memory/context docs](https://github.com/QwenLM/qwen-code).

Expand Down Expand Up @@ -112,7 +112,7 @@ Multiple files can set `root: true` for the same target in project and global mo

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.

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, Gemini CLI, 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), and the rest are `event.type` dispatches — `sessionStart` → `session.created`, `stop` → `session.idle`, `afterFileEdit` → `file.edited`, `afterShellExecution` → `command.executed`, `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 uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI 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).
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, Gemini CLI, 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 uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI 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).

Example:

Expand Down Expand Up @@ -202,7 +202,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s
| `preCompact` | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — |
| `postCompact` | — | ✅ | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — |
| `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
| `beforeShellExecution` | ✅ | — | | | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
| `beforeShellExecution` | ✅ | — | | | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
| `afterShellExecution` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
| `beforeMCPExecution` | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | ✅ | — | — |
| `afterMCPExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — |
Expand Down
27 changes: 27 additions & 0 deletions src/features/hooks/hooks-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,33 @@ describe("HooksProcessor", () => {
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining("matcher"));
});

it("should warn and skip OpenCode matchers on the shell events", async () => {
const rulesyncHooks = new RulesyncHooks({
outputRoot: testDir,
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: "hooks.json",
fileContent: JSON.stringify({
hooks: {
afterShellExecution: [{ command: "filtered.sh", matcher: "npm .*" }],
preToolUse: [{ command: "guard.sh", matcher: "bash" }],
},
}),
validate: false,
});

const processor = new HooksProcessor({ logger, outputRoot: testDir, toolTarget: "opencode" });
const [toolFile] = await processor.convertRulesyncFilesToToolFiles([rulesyncHooks]);

// The named tool.execute.* hooks expose no command text to match
// against, so a matcher-carrying shell hook is skipped with a warning
// rather than silently running on every bash execution.
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipped matcher hook(s) for opencode (not supported)"),
);
expect(toolFile?.getFileContent()).not.toContain("filtered.sh");
expect(toolFile?.getFileContent()).toContain("guard.sh");
});

it("should warn and skip Amp matchers outside tool events", async () => {
const rulesyncHooks = new RulesyncHooks({
outputRoot: testDir,
Expand Down
2 changes: 2 additions & 0 deletions src/features/hooks/hooks-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export const toolHooksFactories = new Map<HooksProcessorToolTarget, ToolHooksFac
supportedEvents: KILO_HOOK_EVENTS,
supportedHookTypes: ["command"],
supportsMatcher: true,
matcherEvents: ["preToolUse", "postToolUse"],
},
],
[
Expand All @@ -330,6 +331,7 @@ export const toolHooksFactories = new Map<HooksProcessorToolTarget, ToolHooksFac
supportedEvents: OPENCODE_HOOK_EVENTS,
supportedHookTypes: ["command"],
supportsMatcher: true,
matcherEvents: ["preToolUse", "postToolUse"],
},
],
[
Expand Down
5 changes: 4 additions & 1 deletion src/features/hooks/kilo-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ describe("KiloHooks", () => {
expect(content).toContain(".rulesync/hooks/audit.sh");
expect(content).toContain('event.type === "file.edited"');
expect(content).toContain("format.sh");
expect(content).toContain('event.type === "command.executed"');
// afterShellExecution is a bash-gated named hook, not the slash-command
// event `command.executed` it was once (wrongly) mapped to.
expect(content).not.toContain("command.executed");
expect(content).toContain('if (input.tool === "bash")');
expect(content).toContain("post-shell.sh");

// permissionRequest maps to generic event permission.asked
Expand Down
39 changes: 36 additions & 3 deletions src/features/hooks/opencode-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ describe("OpencodeHooks", () => {
});

describe("fromRulesyncHooks", () => {
it("should gate shell events on the bash tool inside the named hooks", () => {
const config = {
version: 1,
hooks: {
beforeShellExecution: [{ type: "command", command: "pre-shell.sh" }],
afterShellExecution: [{ type: "command", command: "post-shell.sh" }],
preToolUse: [{ type: "command", command: "pre-tool.sh", matcher: "bash|edit" }],
},
};
const rulesyncHooks = new RulesyncHooks({
relativeDirPath: ".rulesync",
relativeFilePath: "hooks.json",
fileContent: JSON.stringify(config),
});

const content = OpencodeHooks.fromRulesyncHooks({ rulesyncHooks }).getFileContent();

// Both shell events land in the named tool.execute.* hooks with an
// implicit bash gate, alongside the matcher-driven preToolUse handler.
expect(content).toContain('"tool.execute.before": async (input) =>');
expect(content).toContain('"tool.execute.after": async (input) =>');
const gateCount = content.split('if (input.tool === "bash")').length - 1;
expect(gateCount).toBe(2);
expect(content).toContain("pre-shell.sh");
expect(content).toContain("post-shell.sh");
expect(content).toContain("pre-tool.sh");
expect(content).not.toContain("command.executed");
});

it("should filter shared hooks to OpenCode-supported events only", () => {
const config = {
version: 1,
Expand Down Expand Up @@ -80,7 +109,10 @@ describe("OpencodeHooks", () => {
expect(content).toContain(".rulesync/hooks/audit.sh");
expect(content).toContain('event.type === "file.edited"');
expect(content).toContain("format.sh");
expect(content).toContain('event.type === "command.executed"');
// afterShellExecution is a bash-gated named hook, not the slash-command
// event `command.executed` it was once (wrongly) mapped to.
expect(content).not.toContain("command.executed");
expect(content).toContain('if (input.tool === "bash")');
expect(content).toContain("post-shell.sh");

// permissionRequest maps to generic event permission.asked
Expand Down Expand Up @@ -147,8 +179,9 @@ describe("OpencodeHooks", () => {
}).getFileContent();

// `input.tool` does not exist on a compaction hook's input, so the
// matcher is dropped rather than compiled against a missing field.
expect(content).toContain("before-compact.sh");
// matcher cannot be honored; the definition is skipped (the processor
// warns "Skipped matcher hook(s)") rather than run unconditionally.
expect(content).not.toContain("before-compact.sh");
expect(content).not.toContain("input.tool");
});

Expand Down
Loading
Loading