From 47e9f9a1303a261a741e4c299cc04dc0d77173a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 23:05:39 +0000 Subject: [PATCH 1/2] fix: change agent hook default from PostToolBatch/postToolUse to Stop/stop - Change Claude Code hook from PostToolBatch to Stop event - Change Cursor hook from postToolUse to stop event - Remove tool name filtering since stop fires once at session end - Update hook script to use followup_message format for Stop event - Update all tests to reflect new hook behavior - Strip legacy hooks from both new and old event types during install Closes #1647 Co-authored-by: Skosh --- .../src/cli/utils/install-agent-hooks.ts | 84 ++++++------ .../react-doctor/tests/cli-migrations.test.ts | 8 +- .../tests/install-agent-hooks.test.ts | 125 ++++++------------ 3 files changed, 85 insertions(+), 132 deletions(-) diff --git a/packages/react-doctor/src/cli/utils/install-agent-hooks.ts b/packages/react-doctor/src/cli/utils/install-agent-hooks.ts index 1d4b09bf3d..486542e407 100644 --- a/packages/react-doctor/src/cli/utils/install-agent-hooks.ts +++ b/packages/react-doctor/src/cli/utils/install-agent-hooks.ts @@ -51,7 +51,6 @@ const CLAUDE_HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/react-docto const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json"; const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.mjs"; const CURSOR_HOOK_COMMAND = "node .cursor/hooks/react-doctor.mjs"; -const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch"; const CURSOR_HOOKS_SCHEMA_VERSION = 1; // Releases up to 0.5.8 installed a `react-doctor.sh` shell hook; re-installs // must replace those entries (and the orphaned script) instead of stacking a @@ -100,16 +99,22 @@ const readJsonFileSafely = (filePath: string, fallback: Value): Value => // Detection half of the `agent-hooks-sh-to-mjs` migration (cli-migrations.ts): // which supported agents still have a ≤0.5.8 shell hook registered. Checks -// exactly the event keys the installers strip (Claude `PostToolBatch`, Cursor -// `postToolUse`) so one install pass always clears the detection. +// exactly the event keys the installers strip (Claude `Stop`, Cursor `stop`) +// plus legacy `PostToolBatch`/`postToolUse` from ≤0.7.x so one install pass +// always clears the detection. export const findAgentsWithLegacyShellHooks = (projectRoot: string): SkillAgentType[] => { const agents: SkillAgentType[] = []; const settings = readJsonFileSafely( path.join(projectRoot, CLAUDE_SETTINGS_RELATIVE_PATH), {}, ); - const hasLegacyClaudeHook = (settings.hooks?.PostToolBatch ?? []).some((group) => - (group.hooks ?? []).some((hook) => isLegacyHookCommand(hook.command)), + const hasLegacyClaudeHook = ( + (settings.hooks?.PostToolBatch ?? []).some((group) => + (group.hooks ?? []).some((hook) => isLegacyHookCommand(hook.command)), + ) || + (settings.hooks?.Stop ?? []).some((group) => + (group.hooks ?? []).some((hook) => isLegacyHookCommand(hook.command)), + ) ); if (hasLegacyClaudeHook) agents.push(CLAUDE_AGENT); @@ -117,8 +122,13 @@ export const findAgentsWithLegacyShellHooks = (projectRoot: string): SkillAgentT path.join(projectRoot, CURSOR_HOOKS_RELATIVE_PATH), {}, ); - const hasLegacyCursorHook = (config.hooks?.postToolUse ?? []).some((handler) => - isLegacyHookCommand(handler.command), + const hasLegacyCursorHook = ( + (config.hooks?.postToolUse ?? []).some((handler) => + isLegacyHookCommand(handler.command), + ) || + (config.hooks?.stop ?? []).some((handler) => + isLegacyHookCommand(handler.command), + ) ); if (hasLegacyCursorHook) agents.push(CURSOR_AGENT); return agents; @@ -171,18 +181,23 @@ const installClaudeHook = (projectRoot: string): readonly string[] => { const hookPath = path.join(projectRoot, CLAUDE_HOOK_RELATIVE_PATH); const settings = readJsonFile(settingsPath, {}); const hooks = { ...(settings.hooks ?? {}) }; - // Strip legacy entries, dropping a group only when that strip emptied it. + // Strip legacy entries from both Stop and PostToolBatch (PostToolBatch was + // the ≤0.7.x default), dropping a group only when that strip emptied it. // Groups react-doctor never touched (including empty or hook-less ones) pass // through verbatim — the installer must not rewrite settings it doesn't own. - const postToolBatchHooks = (hooks.PostToolBatch ?? []).flatMap((group) => { - const groupHooks = group.hooks ?? []; - const keptHooks = groupHooks.filter((hook) => !isLegacyHookCommand(hook.command)); - if (keptHooks.length === groupHooks.length) return [group]; - return keptHooks.length > 0 ? [{ ...group, hooks: keptHooks }] : []; - }); + const stripLegacyHooks = (groups: readonly ClaudeHookGroup[]): ClaudeHookGroup[] => + groups.flatMap((group) => { + const groupHooks = group.hooks ?? []; + const keptHooks = groupHooks.filter((hook) => !isLegacyHookCommand(hook.command)); + if (keptHooks.length === groupHooks.length) return [group]; + return keptHooks.length > 0 ? [{ ...group, hooks: keptHooks }] : []; + }); - if (!hasClaudeHookCommand(postToolBatchHooks)) { - postToolBatchHooks.push({ + const stopHooks = stripLegacyHooks(hooks.Stop ?? []); + hooks.PostToolBatch = stripLegacyHooks(hooks.PostToolBatch ?? []); + + if (!hasClaudeHookCommand(stopHooks)) { + stopHooks.push({ hooks: [ { type: "command", @@ -192,7 +207,7 @@ const installClaudeHook = (projectRoot: string): readonly string[] => { }); } - hooks.PostToolBatch = postToolBatchHooks; + hooks.Stop = stopHooks; writeJsonFileWithDirectoryCheck(settingsPath, { ...settings, hooks }); writeHookScript(hookPath); @@ -207,19 +222,23 @@ const installCursorHook = (projectRoot: string): readonly string[] => { const hookPath = path.join(projectRoot, CURSOR_HOOK_RELATIVE_PATH); const config = readJsonFile(configPath, {}); const hooks = { ...(config.hooks ?? {}) }; - const postToolUseHooks = (hooks.postToolUse ?? []).filter( + // Strip legacy entries from both stop and postToolUse (postToolUse was the + // ≤0.7.x default). + const stopHooks = (hooks.stop ?? []).filter( + (handler) => !isLegacyHookCommand(handler.command), + ); + hooks.postToolUse = (hooks.postToolUse ?? []).filter( (handler) => !isLegacyHookCommand(handler.command), ); - if (!hasCursorHookCommand(postToolUseHooks)) { - postToolUseHooks.push({ + if (!hasCursorHookCommand(stopHooks)) { + stopHooks.push({ command: CURSOR_HOOK_COMMAND, - matcher: CURSOR_HOOK_MATCHER, timeout: AGENT_HOOK_TIMEOUT_SECONDS, }); } - hooks.postToolUse = postToolUseHooks; + hooks.stop = stopHooks; writeJsonFileWithDirectoryCheck(configPath, { ...config, version: config.version ?? CURSOR_HOOKS_SCHEMA_VERSION, @@ -244,8 +263,6 @@ const buildAgentHookScript = (): string => "// --verbose scans on large diffs can exceed spawnSync's 1 MiB default.", "const SPAWN_MAX_BUFFER_BYTES = 16 * 1024 * 1024;", "", - "const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'ApplyPatch']);", - "", "const readFileOrEmpty = (source) => {", " try {", " return readFileSync(source, 'utf8');", @@ -254,16 +271,6 @@ const buildAgentHookScript = (): string => " }", "};", "", - "const shouldScan = (input) => {", - " const eventName = input.hook_event_name || input.eventName || input.event_name;", - " if (eventName === 'PostToolBatch') {", - " const toolCalls = Array.isArray(input.tool_calls) ? input.tool_calls : [];", - " return toolCalls.some((toolCall) => EDIT_TOOL_NAMES.has(toolCall.tool_name));", - " }", - " const toolName = input.tool_name || input.toolName || input.tool;", - " return !toolName || EDIT_TOOL_NAMES.has(toolName);", - "};", - "", "const runReactDoctor = (outputPath) => {", " // Each candidate is a single shell command string (not an args array):", " // `shell: true` is required to run the Windows `.cmd` shims, and an args", @@ -310,10 +317,6 @@ const buildAgentHookScript = (): string => " input = {};", " }", "", - " if (!shouldScan(input)) {", - " process.exit(0);", - " }", - "", " const projectRoot = process.env.CLAUDE_PROJECT_DIR || join(__dirname, '../..');", " const outputPath = join(tmpdir(), `react-doctor-agent-hook-output-${process.pid}.txt`);", "", @@ -340,8 +343,9 @@ const buildAgentHookScript = (): string => "", " const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing. For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.\\n\\n${scanOutput}`;", "", - " if (input.hook_event_name === 'PostToolBatch') {", - " console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } }));", + " const eventName = input.hook_event_name || input.eventName || input.event_name;", + " if (eventName === 'Stop') {", + " console.log(JSON.stringify({ followup_message: message }));", " } else {", " console.log(JSON.stringify({ additional_context: message }));", " }", diff --git a/packages/react-doctor/tests/cli-migrations.test.ts b/packages/react-doctor/tests/cli-migrations.test.ts index ed6e2c20ec..dfec6f18b3 100644 --- a/packages/react-doctor/tests/cli-migrations.test.ts +++ b/packages/react-doctor/tests/cli-migrations.test.ts @@ -85,7 +85,7 @@ describe("runProjectMigrations", () => { settingsPath, JSON.stringify({ hooks: { - PostToolBatch: [ + Stop: [ { hooks: [ { @@ -106,9 +106,9 @@ describe("runProjectMigrations", () => { const report = await runProjectMigrations(projectRoot); expect(report).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: true, applied: true }); - const settings: { hooks: { PostToolBatch: Array<{ hooks: Array<{ command: string }> }> } } = + const settings: { hooks: { Stop: Array<{ hooks: Array<{ command: string }> }> } } = JSON.parse(fs.readFileSync(path.join(projectRoot, ".claude/settings.json"), "utf8")); - const hookCommands = settings.hooks.PostToolBatch.flatMap((group) => + const hookCommands = settings.hooks.Stop.flatMap((group) => group.hooks.map((hook) => hook.command), ); expect(hookCommands).toHaveLength(1); @@ -143,7 +143,7 @@ describe("runProjectMigrations", () => { const userConfig = JSON.stringify({ version: 1, hooks: { - postToolUse: [{ command: "bash scripts/hooks/react-doctor.sh", matcher: "Write" }], + stop: [{ command: "bash scripts/hooks/react-doctor.sh" }], }, }); fs.writeFileSync(configPath, userConfig); diff --git a/packages/react-doctor/tests/install-agent-hooks.test.ts b/packages/react-doctor/tests/install-agent-hooks.test.ts index adb2573501..6eb01e09be 100644 --- a/packages/react-doctor/tests/install-agent-hooks.test.ts +++ b/packages/react-doctor/tests/install-agent-hooks.test.ts @@ -18,10 +18,7 @@ interface AgentHookJsonOutput { } interface ClaudeAgentHookJsonOutput { - readonly hookSpecificOutput: { - readonly hookEventName: string; - readonly additionalContext: string; - }; + readonly followup_message: string; } interface FakeBinaryOptions { @@ -115,7 +112,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () fixture.cleanup(); }); - it("installs a Claude Code PostToolBatch hook without duplicating existing hooks", () => { + it("installs a Claude Code Stop hook without duplicating existing hooks", () => { const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json"); const hookPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.mjs"); fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); @@ -124,7 +121,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () JSON.stringify({ permissions: { allow: ["Bash(git status)"] }, hooks: { - PostToolBatch: [ + Stop: [ { hooks: [{ type: "command", command: "echo existing" }], }, @@ -144,9 +141,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const settings = readJson<{ permissions: { allow: string[] }; - hooks: { PostToolBatch: Array<{ hooks: Array<{ command: string }> }> }; + hooks: { Stop: Array<{ hooks: Array<{ command: string }> }> }; }>(settingsPath); - const hookCommands = settings.hooks.PostToolBatch.flatMap((group) => + const hookCommands = settings.hooks.Stop.flatMap((group) => group.hooks.map((hook) => hook.command), ); const hookContent = fs.readFileSync(hookPath, "utf8"); @@ -173,7 +170,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () settingsPath, JSON.stringify({ hooks: { - PostToolBatch: [ + Stop: [ { hooks: [ { @@ -193,9 +190,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); const settings = readJson<{ - hooks: { PostToolBatch: Array<{ hooks: Array<{ command: string }> }> }; + hooks: { Stop: Array<{ hooks: Array<{ command: string }> }> }; }>(settingsPath); - const hookCommands = settings.hooks.PostToolBatch.flatMap((group) => + const hookCommands = settings.hooks.Stop.flatMap((group) => group.hooks.map((hook) => hook.command), ); @@ -211,7 +208,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () configPath, JSON.stringify({ version: 1, - hooks: { postToolUse: [{ matcher: "Write" }] }, + hooks: { stop: [{ command: "" }] }, }), ); @@ -222,9 +219,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); }).not.toThrow(); - const config = readJson<{ hooks: { postToolUse: Array<{ command?: string }> } }>(configPath); + const config = readJson<{ hooks: { stop: Array<{ command?: string }> } }>(configPath); expect( - config.hooks.postToolUse.some((handler) => handler.command?.includes("react-doctor.mjs")), + config.hooks.stop.some((handler) => handler.command?.includes("react-doctor.mjs")), ).toBe(true); }); @@ -238,7 +235,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () JSON.stringify({ version: 1, hooks: { - postToolUse: [{ command: ".cursor/hooks/react-doctor.sh", matcher: "Write" }], + stop: [{ command: ".cursor/hooks/react-doctor.sh" }], }, }), ); @@ -249,11 +246,11 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); const config = readJson<{ - hooks: { postToolUse: Array<{ command: string }> }; + hooks: { stop: Array<{ command: string }> }; }>(configPath); - expect(config.hooks.postToolUse).toHaveLength(1); - expect(config.hooks.postToolUse[0].command).toContain("react-doctor.mjs"); + expect(config.hooks.stop).toHaveLength(1); + expect(config.hooks.stop[0].command).toContain("react-doctor.mjs"); expect(fs.existsSync(legacyScriptPath)).toBe(false); }); @@ -264,7 +261,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () settingsPath, JSON.stringify({ hooks: { - PostToolBatch: [{ matcher: "Bash" }, { matcher: "Write", hooks: [] }], + Stop: [{ matcher: "Bash" }, { matcher: "Write", hooks: [] }], }, }), ); @@ -275,14 +272,14 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); const settings = readJson<{ - hooks: { PostToolBatch: Array<{ matcher?: string; hooks?: Array<{ command: string }> }> }; + hooks: { Stop: Array<{ matcher?: string; hooks?: Array<{ command: string }> }> }; }>(settingsPath); - expect(settings.hooks.PostToolBatch).toHaveLength(3); - expect(settings.hooks.PostToolBatch[0]).toEqual({ matcher: "Bash" }); - expect(settings.hooks.PostToolBatch[1]).toEqual({ matcher: "Write", hooks: [] }); + expect(settings.hooks.Stop).toHaveLength(3); + expect(settings.hooks.Stop[0]).toEqual({ matcher: "Bash" }); + expect(settings.hooks.Stop[1]).toEqual({ matcher: "Write", hooks: [] }); expect( - settings.hooks.PostToolBatch[2].hooks?.some((hook) => + settings.hooks.Stop[2].hooks?.some((hook) => hook.command.includes("react-doctor.mjs"), ), ).toBe(true); @@ -297,7 +294,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () settingsPath, JSON.stringify({ hooks: { - PostToolBatch: [ + Stop: [ { hooks: [ { @@ -316,7 +313,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () configPath, JSON.stringify({ version: 1, - hooks: { postToolUse: [{ command: ".cursor/hooks/react-doctor.sh", matcher: "Write" }] }, + hooks: { stop: [{ command: ".cursor/hooks/react-doctor.sh" }] }, }), ); expect(findAgentsWithLegacyShellHooks(fixture.projectRoot)).toEqual(["claude-code", "cursor"]); @@ -334,7 +331,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () configPath, JSON.stringify({ version: 1, - hooks: { postToolUse: [{ command: userWrapperCommand, matcher: "Write" }] }, + hooks: { stop: [{ command: userWrapperCommand }] }, }), ); @@ -343,13 +340,13 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () agents: ["cursor"], }); - const config = readJson<{ hooks: { postToolUse: Array<{ command: string }> } }>(configPath); - const commands = config.hooks.postToolUse.map((handler) => handler.command); + const config = readJson<{ hooks: { stop: Array<{ command: string }> } }>(configPath); + const commands = config.hooks.stop.map((handler) => handler.command); expect(commands).toContain(userWrapperCommand); expect(commands.some((command) => command.includes("react-doctor.mjs"))).toBe(true); }); - it("installs a Cursor postToolUse hook and preserves existing hook config", () => { + it("installs a Cursor stop hook and preserves existing hook config", () => { const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json"); const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs"); fs.mkdirSync(path.dirname(configPath), { recursive: true }); @@ -376,7 +373,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () version: number; hooks: { sessionStart: Array<{ command: string }>; - postToolUse: Array<{ command: string; matcher: string; timeout: number }>; + stop: Array<{ command: string; timeout: number }>; }; }>(configPath); @@ -384,10 +381,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const hookContent = fs.readFileSync(hookPath, "utf8"); expect(config.version).toBe(1); expect(config.hooks.sessionStart).toEqual([{ command: ".cursor/hooks/bootstrap.sh" }]); - expect(config.hooks.postToolUse).toHaveLength(1); - expect(config.hooks.postToolUse[0]).toEqual({ + expect(config.hooks.stop).toHaveLength(1); + expect(config.hooks.stop[0]).toEqual({ command: "node .cursor/hooks/react-doctor.mjs", - matcher: "Write|Edit|MultiEdit|ApplyPatch", timeout: 120, }); expect(fs.existsSync(hookPath)).toBe(true); @@ -409,7 +405,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const output = execFileSync(process.execPath, [hookPath], { cwd: nestedDirectory, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", }), encoding: "utf8", }); @@ -447,8 +443,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () CLAUDE_PROJECT_DIR: fixture.projectRoot, }, input: JSON.stringify({ - hook_event_name: "PostToolBatch", - tool_calls: [{ tool_name: "Write" }], + hook_event_name: "Stop", }), encoding: "utf8", }); @@ -461,11 +456,8 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () .trim(), ), ).toBe(fs.realpathSync(fixture.projectRoot)); - expect(parsedOutput.hookSpecificOutput).toEqual({ - hookEventName: "PostToolBatch", - additionalContext: expect.stringContaining("fake scan output"), - }); - expect(parsedOutput.hookSpecificOutput.additionalContext).toContain("create GitHub issues"); + expect(parsedOutput.followup_message).toContain("fake scan output"); + expect(parsedOutput.followup_message).toContain("create GitHub issues"); }); it("uses a PATH react-doctor binary when the local binary is missing", () => { @@ -494,7 +486,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () ].join(path.delimiter), }, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", }), encoding: "utf8", }); @@ -525,7 +517,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () PATH: "/usr/bin:/bin", }, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", }), encoding: "utf8", }); @@ -534,28 +526,6 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () expect(fs.existsSync(invocationPath)).toBe(false); }); - it("skips generated agent hooks for non-edit tool batches", () => { - const hookPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.mjs"); - const invocationPath = path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt"); - fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true }); - installReactDoctorAgentHooks({ - projectRoot: fixture.projectRoot, - agents: ["claude-code"], - }); - writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 1 }); - - const output = execFileSync(process.execPath, [hookPath], { - cwd: path.join(fixture.projectRoot, ".claude/hooks"), - input: JSON.stringify({ - hook_event_name: "PostToolBatch", - tool_calls: [{ tool_name: "Read" }], - }), - encoding: "utf8", - }); - - expect(output).toBe(""); - expect(fs.existsSync(invocationPath)).toBe(false); - }); it("returns no context when a generated agent hook scan succeeds", () => { const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs"); @@ -569,7 +539,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const output = execFileSync(process.execPath, [hookPath], { cwd: fixture.projectRoot, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", }), encoding: "utf8", }); @@ -580,27 +550,6 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () ).toContain("--verbose"); }); - it("skips generated agent hooks for non-edit single tool events", () => { - const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs"); - const invocationPath = path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt"); - fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true }); - installReactDoctorAgentHooks({ - projectRoot: fixture.projectRoot, - agents: ["cursor"], - }); - writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 1 }); - - const output = execFileSync(process.execPath, [hookPath], { - cwd: fixture.projectRoot, - input: JSON.stringify({ - tool_name: "Read", - }), - encoding: "utf8", - }); - - expect(output).toBe(""); - expect(fs.existsSync(invocationPath)).toBe(false); - }); it("scans when hook input is malformed instead of failing closed", () => { const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs"); From a4e47e85b07c03993a9a88e1d08bbe48ffdccf20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 23:11:25 +0000 Subject: [PATCH 2/2] chore: add changeset for agent hook timing fix Co-authored-by: Skosh --- .changeset/agent-hook-stop-timing.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/agent-hook-stop-timing.md diff --git a/.changeset/agent-hook-stop-timing.md b/.changeset/agent-hook-stop-timing.md new file mode 100644 index 0000000000..0164864a17 --- /dev/null +++ b/.changeset/agent-hook-stop-timing.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Change agent hook default timing from `PostToolBatch`/`postToolUse` to `Stop`/`stop` for better performance and token efficiency. Agent hooks now run once when the agent finishes responding instead of after every tool batch or tool use, reducing scan frequency and improving UX on long sessions with many uncommitted files.