From a2373a0d579922a0c6a8262092f258f9e5278aa0 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Fri, 19 Jun 2026 01:11:25 -0700 Subject: [PATCH] fix(hooks): close Qwen Code hooks gaps (events, http, sequential/disableAllHooks) Add the three Qwen-specific hook events as canonical events and preserve the `http` transport plus the group-level `sequential` and top-level `disableAllHooks` switches on both import and export. - Add canonical `todoCreated`, `todoCompleted`, and `stopFailure` events and map them in `QWENCODE_HOOK_EVENTS` / `CANONICAL_TO_QWENCODE_EVENT_NAMES`. Other tool adapters filter `config.hooks` against their own supported-event set, so the new canonical events are only emitted by Qwen Code. - Preserve the `http` hook type and its `url` field on import instead of collapsing every non-command/prompt hook to `command`. Add `url` to the canonical `HookDefinitionSchema`. - Round-trip the per-matcher-group `sequential` flag (stored per definition) and the top-level `disableAllHooks` switch (under the `qwencode` namespace). - Keep copilotcli's `http` url passthrough working now that `url` is canonical. - Extend unit tests and synchronize docs/skills. Closes #1925 Ref: #1888 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- src/features/hooks/copilotcli-hooks.ts | 7 +- src/features/hooks/qwencode-hooks.test.ts | 138 ++++++++++++++++++++++ src/features/hooks/qwencode-hooks.ts | 42 ++++++- src/types/hooks.ts | 33 +++++- 6 files changed, 210 insertions(+), 14 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 4e8e126d9..ec3dc44d2 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -193,7 +193,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": "..." } ] } ] } }`). Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, `afterShellExecution` ⇄ `AfterShellExecution`, `subagentStart` ⇄ `SubagentStart`, and `subagentStop` ⇄ `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": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, and `notification` ⇄ `Notification`. Qwen-only events (`StopFailure`, `TodoCreated`, `TodoCompleted`) have no canonical equivalent. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting), only `command`-type hooks are supported, and other top-level keys in `settings.json` are preserved on round-trip. 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. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. 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`. 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). ## `.copilot/mcp-config.json` diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 4e8e126d9..ec3dc44d2 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -193,7 +193,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": "..." } ] } ] } }`). Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, `afterShellExecution` ⇄ `AfterShellExecution`, `subagentStart` ⇄ `SubagentStart`, and `subagentStop` ⇄ `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": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Thirteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, and `notification` ⇄ `Notification`. Qwen-only events (`StopFailure`, `TodoCreated`, `TodoCompleted`) have no canonical equivalent. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting), only `command`-type hooks are supported, and other top-level keys in `settings.json` are preserved on round-trip. 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. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. 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`. 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). ## `.copilot/mcp-config.json` diff --git a/src/features/hooks/copilotcli-hooks.ts b/src/features/hooks/copilotcli-hooks.ts index 06bdcd6d4..52a3bcc0e 100644 --- a/src/features/hooks/copilotcli-hooks.ts +++ b/src/features/hooks/copilotcli-hooks.ts @@ -138,7 +138,12 @@ function canonicalToCopilotCliHooks( if (def.prompt === undefined || def.prompt === null) continue; entries.push({ type: "prompt", prompt: def.prompt, ...rest }); } else if (hookType === "http") { - entries.push({ type: "http", ...timeoutPart, ...rest }); + entries.push({ + type: "http", + ...(def.url !== undefined && def.url !== null && { url: def.url }), + ...timeoutPart, + ...rest, + }); } else { const command = def.command; entries.push({ diff --git a/src/features/hooks/qwencode-hooks.test.ts b/src/features/hooks/qwencode-hooks.test.ts index 61154b613..7cdaea6b6 100644 --- a/src/features/hooks/qwencode-hooks.test.ts +++ b/src/features/hooks/qwencode-hooks.test.ts @@ -187,6 +187,78 @@ describe("QwencodeHooks", () => { expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo override"); expect(parsed.hooks.SessionEnd[0].hooks[0].command).toBe("echo end"); }); + + it("should map the new TodoCreated, TodoCompleted, and StopFailure events", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + todoCreated: [{ command: "echo created" }], + todoCompleted: [{ command: "echo completed" }], + stopFailure: [{ command: "echo stop-failure" }], + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.hooks.TodoCreated[0].hooks[0].command).toBe("echo created"); + expect(parsed.hooks.TodoCompleted[0].hooks[0].command).toBe("echo completed"); + expect(parsed.hooks.StopFailure[0].hooks[0].command).toBe("echo stop-failure"); + }); + + it("should preserve the http hook type and its url", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + preToolUse: [{ type: "http", url: "https://example.com/hook", matcher: "Edit" }], + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.hooks.PreToolUse[0].hooks[0].type).toBe("http"); + expect(parsed.hooks.PreToolUse[0].hooks[0].url).toBe("https://example.com/hook"); + }); + + it("should emit group-level sequential and top-level disableAllHooks", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + preToolUse: [{ command: "echo a", matcher: "Edit", sequential: true }], + }, + qwencode: { + disableAllHooks: true, + }, + }), + }), + ); + + const qwencodeHooks = await QwencodeHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(qwencodeHooks.getFileContent()); + expect(parsed.disableAllHooks).toBe(true); + expect(parsed.hooks.PreToolUse[0].sequential).toBe(true); + }); }); describe("toRulesyncHooks", () => { @@ -253,6 +325,72 @@ describe("QwencodeHooks", () => { expect(parsed.hooks.sessionStart?.[0]?.command).toBe("$GEMINI_PROJECT_DIR/echo start"); }); + it("should import the new TodoCreated, TodoCompleted, and StopFailure events", () => { + const qwencodeHooks = new QwencodeHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + TodoCreated: [{ hooks: [{ command: "echo created" }] }], + TodoCompleted: [{ hooks: [{ command: "echo completed" }] }], + StopFailure: [{ hooks: [{ command: "echo stop-failure" }] }], + }, + }), + }), + ); + + const parsed = qwencodeHooks.toRulesyncHooks().getJson(); + expect(parsed.hooks.todoCreated?.[0]?.command).toBe("echo created"); + expect(parsed.hooks.todoCompleted?.[0]?.command).toBe("echo completed"); + expect(parsed.hooks.stopFailure?.[0]?.command).toBe("echo stop-failure"); + }); + + it("should preserve the http hook type and its url on import", () => { + const qwencodeHooks = new QwencodeHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "Edit", + hooks: [{ type: "http", url: "https://example.com/hook" }], + }, + ], + }, + }), + }), + ); + + const parsed = qwencodeHooks.toRulesyncHooks().getJson(); + expect(parsed.hooks.preToolUse?.[0]).toEqual({ + type: "http", + url: "https://example.com/hook", + matcher: "Edit", + }); + }); + + it("should round-trip group-level sequential and top-level disableAllHooks", () => { + const qwencodeHooks = new QwencodeHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + disableAllHooks: true, + hooks: { + PreToolUse: [ + { + matcher: "Edit", + sequential: true, + hooks: [{ type: "command", command: "echo a" }], + }, + ], + }, + }), + }), + ); + + const parsed = qwencodeHooks.toRulesyncHooks().getJson(); + expect(parsed.qwencode?.disableAllHooks).toBe(true); + expect(parsed.hooks.preToolUse?.[0]?.sequential).toBe(true); + }); + it("should ignore invalid entries", () => { const qwencodeHooks = new QwencodeHooks( createMockAiFileParams({ diff --git a/src/features/hooks/qwencode-hooks.ts b/src/features/hooks/qwencode-hooks.ts index fbf0f1e41..02081c806 100644 --- a/src/features/hooks/qwencode-hooks.ts +++ b/src/features/hooks/qwencode-hooks.ts @@ -58,13 +58,23 @@ function canonicalToQwencodeHooks(config: HooksConfig): Record def.sequential === true); + const group: Record = matcherKey + ? { matcher: matcherKey, hooks } + : { hooks }; + if (sequential) { + group.sequential = true; + } + entries.push(group); } qwencode[qwencodeEventName] = entries; } @@ -79,6 +89,8 @@ function canonicalToQwencodeHooks(config: HooksConfig): Record = { ...settings, hooks: qwencodeHooks }; + // Round-trip Qwen Code's top-level switch that disables every hook. + const disableAllHooks = config.qwencode?.disableAllHooks; + if (typeof disableAllHooks === "boolean") { + merged.disableAllHooks = disableAllHooks; + } const fileContent = JSON.stringify(merged, null, 2); return new QwencodeHooks({ outputRoot, @@ -201,7 +226,7 @@ export class QwencodeHooks extends ToolHooks { } toRulesyncHooks(): RulesyncHooks { - let settings: { hooks?: unknown }; + let settings: { hooks?: unknown; disableAllHooks?: unknown }; try { settings = JSON.parse(this.getFileContent()); } catch (error) { @@ -213,8 +238,13 @@ export class QwencodeHooks extends ToolHooks { ); } const hooks = qwencodeHooksToCanonical(settings.hooks); + // Preserve the top-level `disableAllHooks` switch under the qwencode namespace. + const canonical: HooksConfig = + typeof settings.disableAllHooks === "boolean" + ? { version: 1, hooks, qwencode: { disableAllHooks: settings.disableAllHooks } } + : { version: 1, hooks }; return this.toRulesyncHooksDefault({ - fileContent: JSON.stringify({ version: 1, hooks }, null, 2), + fileContent: JSON.stringify(canonical, null, 2), }); } diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 4ad7bf722..33b0ee117 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -26,6 +26,9 @@ export const safeString = z.pipe( export const HookDefinitionSchema = z.looseObject({ command: z.optional(safeString), type: z.optional(z.enum(["command", "prompt", "http"])), + // Qwen Code: target URL for `http` hooks (the hook POSTs JSON to this URL). + // https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md + url: z.optional(safeString), timeout: z.optional(z.number()), matcher: z.optional(safeString), prompt: z.optional(safeString), @@ -35,6 +38,11 @@ export const HookDefinitionSchema = z.looseObject({ // Cursor: when true, a hook failure (crash, timeout, invalid JSON) blocks the // action instead of allowing it through. https://cursor.com/docs/hooks failClosed: z.optional(z.boolean()), + // Qwen Code: when true, the hooks within this matcher group run sequentially + // instead of in parallel (the default). Stored per-definition so it can be + // round-tripped through the canonical, flat list of definitions. + // https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md + sequential: z.optional(z.boolean()), }); export type HookDefinition = z.infer; @@ -80,7 +88,10 @@ export type HookEvent = | "worktreeCreate" | "worktreeRemove" | "workspaceOpen" - | "messageDisplay"; + | "messageDisplay" + | "todoCreated" + | "todoCompleted" + | "stopFailure"; /** Hook events supported by Cursor. */ export const CURSOR_HOOK_EVENTS: readonly HookEvent[] = [ @@ -363,9 +374,9 @@ export const JUNIE_HOOK_EVENTS: readonly HookEvent[] = [ * Qwen Code documents a Claude-style PascalCase hooks surface under the `hooks` * key of `.qwen/settings.json`. Its event set DIFFERS from Gemini CLI's * (`BeforeAgent`/`AfterTool`/...), so qwencode defines its own constant instead - * of reusing {@link GEMINICLI_HOOK_EVENTS}. Only the canonical events with a - * genuine Qwen equivalent are mapped; Qwen-only events (`StopFailure`, - * `TodoCreated`, `TodoCompleted`) have no canonical counterpart and are omitted. + * of reusing {@link GEMINICLI_HOOK_EVENTS}. The Qwen-specific events + * `TodoCreated`, `TodoCompleted`, and `StopFailure` map to the canonical + * `todoCreated`, `todoCompleted`, and `stopFailure` events respectively. * @see https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md */ export const QWENCODE_HOOK_EVENTS: readonly HookEvent[] = [ @@ -376,12 +387,15 @@ export const QWENCODE_HOOK_EVENTS: readonly HookEvent[] = [ "postToolUseFailure", "beforeSubmitPrompt", "stop", + "stopFailure", "subagentStart", "subagentStop", "preCompact", "postCompact", "permissionRequest", "notification", + "todoCreated", + "todoCompleted", ]; const hooksRecordSchema = z.record(z.string(), z.array(HookDefinitionSchema)); @@ -411,7 +425,13 @@ export const HooksConfigSchema = z.looseObject({ "antigravity-cli": z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), junie: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), vibe: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), - qwencode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), + qwencode: z.optional( + z.looseObject({ + hooks: z.optional(hooksRecordSchema), + // Qwen Code top-level switch that disables every hook when true. + disableAllHooks: z.optional(z.boolean()), + }), + ), }); export type HooksConfig = z.infer; @@ -779,10 +799,13 @@ export const CANONICAL_TO_QWENCODE_EVENT_NAMES: Record = { stop: "Stop", subagentStart: "SubagentStart", subagentStop: "SubagentStop", + stopFailure: "StopFailure", preCompact: "PreCompact", postCompact: "PostCompact", permissionRequest: "PermissionRequest", notification: "Notification", + todoCreated: "TodoCreated", + todoCompleted: "TodoCompleted", }; /**