diff --git a/.gitignore b/.gitignore index 6c0e9e106..af7c8cf41 100644 --- a/.gitignore +++ b/.gitignore @@ -226,6 +226,7 @@ docs/.vitepress/cache rulesync.local.jsonc **/AGENTS.local.md **/CLAUDE.local.md +**/.qwen/QWEN.local.md **/.claude/CLAUDE.local.md **/.claude/*.lock **/.claude/settings.local.json diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 7950ad146..4afbb0050 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -77,7 +77,7 @@ Multiple files can set `root: true` for the same target in project and global mo > **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. -> **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. See the [Qwen Code memory/context docs](https://github.com/QwenLM/qwen-code). +> **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). > **Cline note:** Cline writes the root rule to the auto-loaded `AGENTS.md` (project) as plain Markdown, and non-root rules to its flat `.clinerules/` directory. Each non-root rule is a Markdown file with optional YAML frontmatter for conditional activation: Rulesync maps `globs` ⇄ Cline's `paths` (a glob array; the rule loads only when a matching file is in context) and `description` ⇄ `description`. A rule with **specific** `globs` emits `paths`; a rule with **universal** globs (`**/*` or `*`) emits `alwaysApply: true` (always load); a rule **without** globs is written as plain Markdown with no frontmatter block (always active). In global mode (via `--global`), the root rule is written to the cross-tool `~/.agents/AGENTS.md` (Cline CLI v3.0.15+) as plain Markdown, and non-root rules go to `~/Documents/Cline/Rules/*.md` — the global modular-rules directory both the VS Code extension and the SDK/CLI read — with the same conditional-frontmatter conversion project rules get. See the [Cline rules docs](https://docs.cline.bot/customization/cline-rules). @@ -169,7 +169,7 @@ Example: - `shell` (optional): Either `"bash"` or `"powershell"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code and Claude Code command hooks. Like `args`, `async` and `asyncRewake`, it is documented on command hooks only, so it is not emitted on a hook of another type. - `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks. - `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks. -- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks. +- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks and to Qwen Code prompt hooks. - `args` (optional, `command` hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns `command` directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder `${CLAUDE_PROJECT_DIR}/…` that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code and AugmentCode. Only `command` is prefixed; entries of `args` are passed through exactly as written. - `asyncRewake` (optional): boolean. Like `async`, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks. - `once` (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks. @@ -268,7 +268,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }`). Eleven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, and `afterShellExecution` ⇄ `AfterShellExecution` — matching Goose's `HookEvent` enum exactly (it has no `SubagentStart`/`SubagentStop`). The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported. -> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Twenty-one lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `postToolBatch` ⇄ `PostToolBatch`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `userPromptExpansion` ⇄ `UserPromptExpansion`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `permissionDenied` ⇄ `PermissionDenied`, `notification` ⇄ `Notification`, `instructionsLoaded` ⇄ `InstructionsLoaded`, `todoCreated` ⇄ `TodoCreated`, `todoCompleted` ⇄ `TodoCompleted`, and `messageDisplay` ⇄ `MessageDisplay` (fires repeatedly as the reply streams; added in Qwen Code v0.19.10). Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). +> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Twenty-one lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `postToolBatch` ⇄ `PostToolBatch`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `userPromptExpansion` ⇄ `UserPromptExpansion`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `permissionDenied` ⇄ `PermissionDenied`, `notification` ⇄ `Notification`, `instructionsLoaded` ⇄ `InstructionsLoaded`, `todoCreated` ⇄ `TodoCreated`, `todoCompleted` ⇄ `TodoCompleted`, and `messageDisplay` ⇄ `MessageDisplay` (fires repeatedly as the reply streams; added in Qwen Code v0.19.10). Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt` (which carries the required `prompt` body — with `$ARGUMENTS` interpolation — and an optional `model` override, both round-tripped; a prompt hook without a `prompt` is warned about at generate time since Qwen Code loads it and fails it at runtime), `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). > **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. All ten of Reasonix's documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `subagentStop` ⇄ `SubagentStop`, `postModelInvocation` ⇄ `PostLLMCall`, `notification` ⇄ `Notification`, and `preCompact` ⇄ `PreCompact`. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index cf82cd9fc..8e52bcbb5 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -77,7 +77,7 @@ Multiple files can set `root: true` for the same target in project and global mo > **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. -> **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. See the [Qwen Code memory/context docs](https://github.com/QwenLM/qwen-code). +> **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). > **Cline note:** Cline writes the root rule to the auto-loaded `AGENTS.md` (project) as plain Markdown, and non-root rules to its flat `.clinerules/` directory. Each non-root rule is a Markdown file with optional YAML frontmatter for conditional activation: Rulesync maps `globs` ⇄ Cline's `paths` (a glob array; the rule loads only when a matching file is in context) and `description` ⇄ `description`. A rule with **specific** `globs` emits `paths`; a rule with **universal** globs (`**/*` or `*`) emits `alwaysApply: true` (always load); a rule **without** globs is written as plain Markdown with no frontmatter block (always active). In global mode (via `--global`), the root rule is written to the cross-tool `~/.agents/AGENTS.md` (Cline CLI v3.0.15+) as plain Markdown, and non-root rules go to `~/Documents/Cline/Rules/*.md` — the global modular-rules directory both the VS Code extension and the SDK/CLI read — with the same conditional-frontmatter conversion project rules get. See the [Cline rules docs](https://docs.cline.bot/customization/cline-rules). @@ -169,7 +169,7 @@ Example: - `shell` (optional): Either `"bash"` or `"powershell"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code and Claude Code command hooks. Like `args`, `async` and `asyncRewake`, it is documented on command hooks only, so it is not emitted on a hook of another type. - `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks. - `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks. -- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks. +- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks and to Qwen Code prompt hooks. - `args` (optional, `command` hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns `command` directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder `${CLAUDE_PROJECT_DIR}/…` that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code and AugmentCode. Only `command` is prefixed; entries of `args` are passed through exactly as written. - `asyncRewake` (optional): boolean. Like `async`, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks. - `once` (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks. @@ -268,7 +268,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }`). Eleven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, and `afterShellExecution` ⇄ `AfterShellExecution` — matching Goose's `HookEvent` enum exactly (it has no `SubagentStart`/`SubagentStop`). The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported. -> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Twenty-one lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `postToolBatch` ⇄ `PostToolBatch`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `userPromptExpansion` ⇄ `UserPromptExpansion`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `permissionDenied` ⇄ `PermissionDenied`, `notification` ⇄ `Notification`, `instructionsLoaded` ⇄ `InstructionsLoaded`, `todoCreated` ⇄ `TodoCreated`, `todoCompleted` ⇄ `TodoCompleted`, and `messageDisplay` ⇄ `MessageDisplay` (fires repeatedly as the reply streams; added in Qwen Code v0.19.10). Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). +> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Twenty-one lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `postToolBatch` ⇄ `PostToolBatch`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `userPromptExpansion` ⇄ `UserPromptExpansion`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `permissionDenied` ⇄ `PermissionDenied`, `notification` ⇄ `Notification`, `instructionsLoaded` ⇄ `InstructionsLoaded`, `todoCreated` ⇄ `TodoCreated`, `todoCompleted` ⇄ `TodoCompleted`, and `messageDisplay` ⇄ `MessageDisplay` (fires repeatedly as the reply streams; added in Qwen Code v0.19.10). Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt` (which carries the required `prompt` body — with `$ARGUMENTS` interpolation — and an optional `model` override, both round-tripped; a prompt hook without a `prompt` is warned about at generate time since Qwen Code loads it and fails it at runtime), `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). > **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. All ten of Reasonix's documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `subagentStop` ⇄ `SubagentStop`, `postModelInvocation` ⇄ `PostLLMCall`, `notification` ⇄ `Notification`, and `preCompact` ⇄ `PreCompact`. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index 775a90b19..8ffd8d8a5 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -142,6 +142,9 @@ describe("registry derivation", () => { const justified = new Set([ // rulesync meta files and local-root files (not in any getSettablePaths). "claudecode::rules::**/CLAUDE.local.md", + // Qwen Code's personal project context file, emitted for localRoot rules + // but never committable (issue #2507). + "qwencode::rules::**/.qwen/QWEN.local.md", "claudecode::rules::**/.claude/CLAUDE.local.md", "claudecode::general::**/.claude/*.lock", "claudecode::general::**/.claude/settings.local.json", diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index bac02927b..74850b558 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -5,6 +5,7 @@ import { CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, } from "../../constants/claudecode-paths.js"; import { CODEXCLI_BASH_RULES_FILE_NAME, CODEXCLI_DIR } from "../../constants/codexcli-paths.js"; +import { QWENCODE_DIR, QWENCODE_LOCAL_RULE_FILE_NAME } from "../../constants/qwencode-paths.js"; import { RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH, @@ -60,6 +61,13 @@ export const HAND_MAINTAINED_GITIGNORE_ENTRIES: ReadonlyArray // Local-root rule files: materialized outside getSettablePaths. { target: "claudecode", feature: "rules", entry: `**/${CLAUDECODE_LOCAL_RULE_FILE_NAME}` }, + // Qwen Code's personal project context file (v0.16.2): emitted for localRoot + // rules but must not be committed (Qwen Code does not gitignore it itself). + { + target: "qwencode", + feature: "rules", + entry: `**/${QWENCODE_DIR}/${QWENCODE_LOCAL_RULE_FILE_NAME}`, + }, { target: "claudecode", feature: "rules", diff --git a/src/constants/qwencode-paths.ts b/src/constants/qwencode-paths.ts index 7994513a6..a10aec394 100644 --- a/src/constants/qwencode-paths.ts +++ b/src/constants/qwencode-paths.ts @@ -5,5 +5,10 @@ export const QWENCODE_COMMANDS_DIR_PATH = join(QWENCODE_DIR, "commands"); export const QWENCODE_AGENTS_DIR_PATH = join(QWENCODE_DIR, "agents"); export const QWENCODE_SKILLS_DIR_PATH = join(QWENCODE_DIR, "skills"); export const QWENCODE_RULE_FILE_NAME = "QWEN.md"; +// Personal project-scoped context file (v0.16.2): loads after the shared +// QWEN.md so it can override team instructions. Qwen Code does not gitignore +// it for the user, so rulesync hand-maintains a gitignore entry for it. +// https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/memory.md +export const QWENCODE_LOCAL_RULE_FILE_NAME = "QWEN.local.md"; export const QWENCODE_IGNORE_FILE_NAME = ".qwenignore"; export const QWENCODE_SETTINGS_FILE_NAME = "settings.json"; diff --git a/src/features/hooks/qwencode-hooks.test.ts b/src/features/hooks/qwencode-hooks.test.ts index 14b146a27..0a7bdbb83 100644 --- a/src/features/hooks/qwencode-hooks.test.ts +++ b/src/features/hooks/qwencode-hooks.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { createMockLogger } from "../../test-utils/mock-logger.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; import { QwencodeHooks } from "./qwencode-hooks.js"; @@ -34,6 +35,64 @@ describe("QwencodeHooks", () => { }); describe("fromRulesyncHooks", () => { + it("should emit prompt and model fields on prompt hooks", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + stop: [ + { + type: "prompt", + prompt: "Decide on $ARGUMENTS", + model: "qwen3-coder-flash", + name: "guard", + }, + ], + // A command hook must not pick up prompt/model. + preToolUse: [{ type: "command", command: "echo pre" }], + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + const promptHook = parsed.hooks.Stop[0].hooks[0]; + expect(promptHook.type).toBe("prompt"); + expect(promptHook.prompt).toBe("Decide on $ARGUMENTS"); + expect(promptHook.model).toBe("qwen3-coder-flash"); + expect(promptHook.name).toBe("guard"); + expect(parsed.hooks.PreToolUse[0].hooks[0].prompt).toBeUndefined(); + expect(parsed.hooks.PreToolUse[0].hooks[0].model).toBeUndefined(); + }); + + it("should warn when a prompt hook has no prompt body", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + stop: [{ type: "prompt", name: "empty-guard" }], + }, + }), + }), + ); + const logger = createMockLogger(); + + await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + logger, + }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("no 'prompt' field")); + }); + it("should map canonical events to Qwen Code PascalCase and filter unsupported events", async () => { const rulesyncHooks = new RulesyncHooks( createMockAiFileParams({ @@ -428,6 +487,34 @@ describe("QwencodeHooks", () => { }); describe("toRulesyncHooks", () => { + it("should import prompt and model fields from prompt hooks", () => { + const qwencodeHooks = new QwencodeHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [ + { + type: "prompt", + prompt: "Decide on $ARGUMENTS", + model: "qwen3-coder-flash", + }, + ], + }, + ], + }, + }), + }), + ); + + const parsed = qwencodeHooks.toRulesyncHooks().getJson() as any; + const def = parsed.hooks.stop[0]; + expect(def.type).toBe("prompt"); + expect(def.prompt).toBe("Decide on $ARGUMENTS"); + expect(def.model).toBe("qwen3-coder-flash"); + }); + it("should round-trip Qwen Code PascalCase back to canonical", () => { const qwencodeHooks = new QwencodeHooks( createMockAiFileParams({ diff --git a/src/features/hooks/qwencode-hooks.ts b/src/features/hooks/qwencode-hooks.ts index d6a8fbb6d..31c4bea91 100644 --- a/src/features/hooks/qwencode-hooks.ts +++ b/src/features/hooks/qwencode-hooks.ts @@ -13,6 +13,7 @@ import { } from "../../types/hooks.js"; import { formatError } from "../../utils/error.js"; import { readFileContentOrNull } from "../../utils/file.js"; +import type { Logger } from "../../utils/logger.js"; import { compact } from "../../utils/object.js"; import { applySharedConfigPatch, sharedConfigFileKey } from "../shared/shared-config-gateway.js"; import type { RulesyncHooks } from "./rulesync-hooks.js"; @@ -38,6 +39,7 @@ function canonicalDefToQwencodeHook( const type = def.type ?? "command"; const isHttp = type === "http"; const isCommand = type === "command"; + const isPrompt = type === "prompt"; return { type, ...compact({ @@ -47,6 +49,11 @@ function canonicalDefToQwencodeHook( name: def.name, description: def.description, statusMessage: def.statusMessage, + // Prompt-only per-hook fields (Qwen Code PR #3388): the LLM prompt body + // (required upstream; `$ARGUMENTS` interpolation) and optional model + // override. + prompt: isPrompt ? def.prompt : undefined, + model: isPrompt ? def.model : undefined, // Command-only per-hook fields (Qwen Code PR #2827) — upstream documents // these for command hooks only, so gate on the command type explicitly. async: isCommand ? def.async : undefined, @@ -69,7 +76,7 @@ function canonicalDefToQwencodeHook( * is needed here — commands are passed through verbatim and any such variable * reference stays intact for Qwen Code to expand. */ -function canonicalToQwencodeHooks(config: HooksConfig): Record { +function canonicalToQwencodeHooks(config: HooksConfig, logger?: Logger): Record { const qwencodeSupported: Set = new Set(QWENCODE_HOOK_EVENTS); const sharedHooks: HooksConfig["hooks"] = {}; for (const [event, defs] of Object.entries(config.hooks)) { @@ -91,6 +98,13 @@ function canonicalToQwencodeHooks(config: HooksConfig): Record(); for (const def of definitions) { if (!qwencodeSupportedTypes.has(def.type ?? "command")) continue; + // A prompt hook without a prompt body loads in Qwen Code and then fails + // silently at runtime, so surface the misconfiguration at generate time. + if (def.type === "prompt" && !def.prompt) { + logger?.warn( + `Qwen Code prompt hook on '${eventName}' has no 'prompt' field; Qwen Code will load it and fail it at runtime.`, + ); + } const key = def.matcher ?? ""; const list = byMatcher.get(key); if (list) list.push(def); @@ -140,6 +154,10 @@ const QwencodeHookEntrySchema = z.looseObject({ headers: z.optional(z.record(z.string(), z.string())), allowedEnvVars: z.optional(z.array(z.string())), once: z.optional(z.boolean()), + // Prompt-hook fields (Qwen Code PR #3388): the LLM prompt body (required + // upstream) and optional model override. + prompt: z.optional(z.string()), + model: z.optional(z.string()), }); /** @@ -156,52 +174,66 @@ const QwencodeMatcherEntrySchema = z.looseObject({ /** * Convert a single parsed Qwen Code matcher group into canonical hook definitions. */ +function qwencodeHookEntryToCanonical({ + hook, + sequential, + matcher, +}: { + hook: z.infer; + sequential: boolean; + matcher: string | undefined; +}): HooksConfig["hooks"][string][number] { + const h = hook; + // Preserve every documented Qwen Code hook type (`command`, `prompt`, + // `http`, `function`) instead of collapsing unknown types to `command`. + const hookType = + h.type === "command" || h.type === "prompt" || h.type === "http" || h.type === "function" + ? h.type + : "command"; + const isHttp = hookType === "http"; + const isCommand = hookType === "command"; + const isPrompt = hookType === "prompt"; + // The canonical schema restricts `shell` to the two values Qwen accepts; + // drop anything else rather than failing the whole import. + const shell = h.shell === "bash" || h.shell === "powershell" ? h.shell : undefined; + return { + type: hookType, + ...compact({ + command: h.command, + url: h.url, + timeout: h.timeout, + name: h.name, + description: h.description, + // `statusMessage` applies to both command and http hooks. + statusMessage: h.statusMessage, + // Command-only per-hook fields (Qwen Code PR #2827) — command type only. + async: isCommand ? h.async : undefined, + env: isCommand ? h.env : undefined, + shell: isCommand ? shell : undefined, + // Http-only per-hook fields (Qwen Code PR #2827). + headers: isHttp ? h.headers : undefined, + allowedEnvVars: isHttp ? h.allowedEnvVars : undefined, + once: isHttp ? h.once : undefined, + // Prompt-only per-hook fields (Qwen Code PR #3388). + prompt: isPrompt ? h.prompt : undefined, + model: isPrompt ? h.model : undefined, + sequential: sequential ? true : undefined, + matcher, + }), + }; +} + function qwencodeMatcherEntryToCanonical( entry: z.infer, ): HooksConfig["hooks"][string] { - const defs: HooksConfig["hooks"][string] = []; - const hooks = entry.hooks ?? []; const sequential = entry.sequential === true; const matcher = entry.matcher !== undefined && entry.matcher !== null && entry.matcher !== "" ? entry.matcher : undefined; - for (const h of hooks) { - // Preserve every documented Qwen Code hook type (`command`, `prompt`, - // `http`, `function`) instead of collapsing unknown types to `command`. - const hookType = - h.type === "command" || h.type === "prompt" || h.type === "http" || h.type === "function" - ? h.type - : "command"; - const isHttp = hookType === "http"; - const isCommand = hookType === "command"; - // The canonical schema restricts `shell` to the two values Qwen accepts; - // drop anything else rather than failing the whole import. - const shell = h.shell === "bash" || h.shell === "powershell" ? h.shell : undefined; - defs.push({ - type: hookType, - ...compact({ - command: h.command, - url: h.url, - timeout: h.timeout, - name: h.name, - description: h.description, - // `statusMessage` applies to both command and http hooks. - statusMessage: h.statusMessage, - // Command-only per-hook fields (Qwen Code PR #2827) — command type only. - async: isCommand ? h.async : undefined, - env: isCommand ? h.env : undefined, - shell: isCommand ? shell : undefined, - // Http-only per-hook fields (Qwen Code PR #2827). - headers: isHttp ? h.headers : undefined, - allowedEnvVars: isHttp ? h.allowedEnvVars : undefined, - once: isHttp ? h.once : undefined, - sequential: sequential ? true : undefined, - matcher, - }), - }); - } - return defs; + return (entry.hooks ?? []).map((hook) => + qwencodeHookEntryToCanonical({ hook, sequential, matcher }), + ); } /** @@ -266,12 +298,13 @@ export class QwencodeHooks extends ToolHooks { rulesyncHooks, validate = true, global = false, + logger, }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): Promise { const paths = QwencodeHooks.getSettablePaths({ global }); const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); const existingContent = (await readFileContentOrNull(filePath)) ?? JSON.stringify({}, null, 2); const config = rulesyncHooks.getJson(); - const patch: Record = { hooks: canonicalToQwencodeHooks(config) }; + const patch: Record = { hooks: canonicalToQwencodeHooks(config, logger) }; // Round-trip Qwen Code's top-level switch that disables every hook. const disableAllHooks = config.qwencode?.disableAllHooks; if (typeof disableAllHooks === "boolean") { diff --git a/src/features/rules/qwencode-rule.ts b/src/features/rules/qwencode-rule.ts index 9eb7b611d..9c8c6bced 100644 --- a/src/features/rules/qwencode-rule.ts +++ b/src/features/rules/qwencode-rule.ts @@ -350,4 +350,18 @@ export class QwencodeRule extends ToolRule { toolTarget: "qwencode", }); } + + /** + * The personal local context file lives under `.qwen/`, not at the project + * root where the settable root path points, so override the deletion glob. + */ + static getLocalRootDeletionGlob({ + outputRoot, + fileName, + }: { + outputRoot: string; + fileName: string; + }): string { + return join(outputRoot, QWENCODE_DIR, fileName); + } } diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 8ff712a27..44a819b10 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -86,6 +86,34 @@ describe("RulesProcessor", () => { expect(result[1]).toBeInstanceOf(CopilotRule); }); + it("should emit a localRoot rule to .qwen/QWEN.local.md for qwencode", async () => { + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "qwencode" }); + + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "root.md", + frontmatter: { targets: ["*"], root: true }, + body: "Shared team instructions", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "local.md", + frontmatter: { targets: ["*"], localRoot: true }, + body: "Personal overrides", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + + const localRule = result.find((rule) => rule.getRelativeFilePath() === "QWEN.local.md"); + expect(localRule).toBeDefined(); + expect(localRule?.getRelativeDirPath()).toBe(".qwen"); + expect(localRule?.getFileContent()).toBe("Personal overrides"); + }); + it("should return empty array when no rules match the tool target", async () => { const processor = new RulesProcessor({ logger, toolTarget: "warp" }); @@ -1049,6 +1077,27 @@ Content that would fail parsing`; expect(filePaths).toContain("CLAUDE.local.md"); }); + it("should include .qwen/QWEN.local.md for deletion for qwencode", async () => { + await writeFileContent(join(testDir, "QWEN.md"), "# Root"); + await writeFileContent(join(testDir, ".qwen", "QWEN.local.md"), "# Local"); + + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: "qwencode", + }); + + const filesToDelete = await processor.loadToolFiles({ + forDeletion: true, + }); + + const filePaths = filesToDelete.map((f) => + join(f.getRelativeDirPath(), f.getRelativeFilePath()), + ); + expect(filePaths).toContain(join(".", "QWEN.md")); + expect(filePaths).toContain(join(".qwen", "QWEN.local.md")); + }); + it("should include CLAUDE.local.md for deletion for claudecode-legacy", async () => { await writeFileContent(join(testDir, "CLAUDE.md"), "# Root"); await writeFileContent(join(testDir, "CLAUDE.local.md"), "# Local"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 90e1146cc..587d80ab8 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -4,6 +4,7 @@ import { encode } from "@toon-format/toon"; import { z } from "zod/mini"; import { SKILL_FILE_NAME } from "../../constants/general.js"; +import { QWENCODE_DIR, QWENCODE_LOCAL_RULE_FILE_NAME } from "../../constants/qwencode-paths.js"; import { RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH, RULESYNC_RULES_RELATIVE_DIR_PATH, @@ -676,6 +677,10 @@ export const toolRuleFactories = new Map