diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index e518972a1c2..ab3ea54d806 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -23,6 +23,7 @@ import { z } from "zod" * - `mistake_limit_reached`: Too many errors encountered, needs user guidance on how to proceed * - `use_mcp_server`: Permission to use Model Context Protocol (MCP) server functionality * - `auto_approval_max_req_reached`: Auto-approval limit has been reached, manual approval required + * - `hook`: Approval to execute a prompt-based hook at an agent lifecycle event */ export const clineAsks = [ "followup", @@ -36,6 +37,7 @@ export const clineAsks = [ "mistake_limit_reached", "use_mcp_server", "auto_approval_max_req_reached", + "hook", ] as const export const clineAskSchema = z.enum(clineAsks) @@ -81,7 +83,13 @@ export function isResumableAsk(ask: ClineAsk): ask is ResumableAsk { * Asks that put the task into an "user interaction required" state. */ -export const interactiveAsks = ["followup", "command", "tool", "use_mcp_server"] as const satisfies readonly ClineAsk[] +export const interactiveAsks = [ + "followup", + "command", + "tool", + "use_mcp_server", + "hook", +] as const satisfies readonly ClineAsk[] export type InteractiveAsk = (typeof interactiveAsks)[number] @@ -170,6 +178,7 @@ export const clineSays = [ "user_edit_todos", "too_many_tools_warning", "tool", + "hook_output", ] as const export const clineSaySchema = z.enum(clineSays) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be154..c6fffdace8d 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -1,4 +1,6 @@ import { serializeError } from "serialize-error" +import { executeHooks, formatHookResults } from "../../services/hooks/HookExecutor" +import type { HookContext } from "../../shared/hooks" import { Anthropic } from "@anthropic-ai/sdk" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" @@ -553,6 +555,12 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult(formatResponse.toolError(errorString)) } + // === askHookApproval: separate approval flow for prompt-based hooks === + const askHookApproval = async (hookEvent: string, hookDescription: string): Promise => { + const { response } = await cline.ask("hook", JSON.stringify({ hookEvent, hookDescription })) + return response === "yesButtonClicked" + } + if (!block.partial) { // Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools) const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name) @@ -675,6 +683,37 @@ export async function presentAssistantMessage(cline: Task) { } } + // === PreToolUse Hook (fires for ALL tools at the central location) === + if (!block.partial) { + try { + const hooksManager = cline.providerRef.deref()?.getHooksManager() + if (hooksManager?.hasHooksForEvent("PreToolUse")) { + const matchingHooks = hooksManager.getMatchingHooks("PreToolUse", block.name) + if (matchingHooks.length > 0) { + const hookDescription = `PreToolUse hook for ${block.name} (${matchingHooks.length} hook(s) will run)` + const approved = await askHookApproval("PreToolUse", hookDescription) + if (approved) { + const hookContext: HookContext = { + event: "PreToolUse", + toolName: block.name, + toolInput: block.nativeArgs || block.params, + } + const hookResults = await executeHooks( + matchingHooks, + hookContext, + cline.apiConfiguration, + ) + const hookOutput = formatHookResults(hookResults) + if (hookOutput) { + await cline.say("hook_output", `PreToolUse hook for ${block.name}:\n${hookOutput}`) + } + } + } + } + } catch (hookError) { + console.warn(`[presentAssistantMessage] PreToolUse hook error:`, hookError) + } + } switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) @@ -917,6 +956,54 @@ export async function presentAssistantMessage(cline: Task) { } } + // === PostToolUse Hook === + if (!block.partial) { + try { + const hooksManager = cline.providerRef.deref()?.getHooksManager() + if (hooksManager?.hasHooksForEvent("PostToolUse")) { + const matchingHooks = hooksManager.getMatchingHooks("PostToolUse", block.name) + if (matchingHooks.length > 0) { + const hookDescription = `PostToolUse hook for ${block.name} (${matchingHooks.length} hook(s) will run)` + const approved = await askHookApproval("PostToolUse", hookDescription) + if (approved) { + // Get the last tool result text for context + const lastResult = cline.userMessageContent + .filter( + (b): b is import("@anthropic-ai/sdk").Anthropic.ToolResultBlockParam => + b.type === "tool_result", + ) + .pop() + const resultText = + typeof lastResult?.content === "string" + ? lastResult.content + : Array.isArray(lastResult?.content) + ? lastResult.content + .filter((b): b is Anthropic.TextBlockParam => b.type === "text") + .map((b) => b.text) + .join("\n") || "" + : "" + const hookContext: HookContext = { + event: "PostToolUse", + toolName: block.name, + toolResult: resultText.slice(0, 2000), + } + const hookResults = await executeHooks( + matchingHooks, + hookContext, + cline.apiConfiguration, + ) + const hookOutput = formatHookResults(hookResults) + if (hookOutput) { + await cline.say("hook_output", `PostToolUse hook for ${block.name}:\n${hookOutput}`) + } + } + } + } + } catch (hookError) { + console.warn(`[presentAssistantMessage] PostToolUse hook error:`, hookError) + } + } + break } } diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index a406a15c8b4..d6b04b397c7 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -10,6 +10,8 @@ import type { ToolUse } from "../../shared/tools" import { t } from "../../i18n" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { executeHooks, formatHookResults } from "../../services/hooks/HookExecutor" +import type { HookContext } from "../../shared/hooks" interface AttemptCompletionParams { result: string @@ -78,6 +80,29 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { task.consecutiveMistakeCount = 0 + // === Stop Hook === + try { + const hooksManager = task.providerRef.deref()?.getHooksManager() + if (hooksManager?.hasHooksForEvent("Stop")) { + const stopHooks = hooksManager.getHooksForEvent("Stop") + const hookDescription = `Stop hook (${stopHooks.length} hook(s) will run)` + const { response } = await task.ask("hook", JSON.stringify({ hookEvent: "Stop", hookDescription })) + if (response === "yesButtonClicked") { + const hookContext: HookContext = { + event: "Stop", + completionResult: result, + } + const hookResults = await executeHooks(stopHooks, hookContext, task.apiConfiguration) + const hookOutput = formatHookResults(hookResults) + if (hookOutput) { + await task.say("hook_output", `Stop hook:\n${hookOutput}`) + } + } + } + } catch (hookError) { + console.warn(`[AttemptCompletionTool] Stop hook error:`, hookError) + } + await task.say("completion_result", result, undefined, false) // Force final token usage update before emitting TaskCompleted diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bb9199a65c2..8bbfec62f4f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -76,6 +76,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" +import { HooksManager } from "../../services/hooks/HooksManager" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -142,6 +143,7 @@ export class ClineProvider private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager + protected hooksManager?: HooksManager private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -216,6 +218,12 @@ export class ClineProvider this.log(`Failed to initialize Skills Manager: ${error}`) }) + // Initialize Hooks Manager for prompt-based hooks + this.hooksManager = new HooksManager(this.cwd) + this.hooksManager.initialize().catch((error) => { + this.log(`Failed to initialize Hooks Manager: ${error}`) + }) + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -664,6 +672,7 @@ export class ClineProvider await this.mcpHub?.unregisterClient() this.mcpHub = undefined await this.skillsManager?.dispose() + this.hooksManager?.dispose() this.skillsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() @@ -2717,6 +2726,10 @@ export class ClineProvider return this.skillsManager } + public getHooksManager(): HooksManager | undefined { + return this.hooksManager + } + /** * Check if the current state is compliant with MDM policy * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant diff --git a/src/services/hooks/HookExecutor.ts b/src/services/hooks/HookExecutor.ts new file mode 100644 index 00000000000..11bb8fbff88 --- /dev/null +++ b/src/services/hooks/HookExecutor.ts @@ -0,0 +1,147 @@ +/** + * HookExecutor runs prompt-based hooks by sending the hook prompt (with context) + * to the configured API provider via the singleCompletionHandler. + * + * Hooks are read-only: they have no tool access and can only produce advisory text. + * The advisory text is injected into the conversation as informational context. + */ + +import type { ProviderSettings } from "@roo-code/types" + +import { singleCompletionHandler } from "../../utils/single-completion-handler" +import type { HookDefinition, HookEventName, HookContext, HookResult } from "../../shared/hooks" + +/** + * Builds a full prompt string from the hook definition and context. + */ +export function buildHookPrompt(hook: HookDefinition, context: HookContext): string { + const parts: string[] = [] + + parts.push("You are a hook that runs at a specific point in an AI coding assistant's workflow.") + parts.push("Your role is advisory only - you cannot use tools or take actions.") + parts.push("Provide concise, actionable feedback based on the context below.") + parts.push("") + + parts.push(`## Event: ${context.event}`) + parts.push("") + + if (context.toolName) { + parts.push(`**Tool:** ${context.toolName}`) + } + + if (context.toolInput) { + parts.push(`**Tool Input:**`) + parts.push("```json") + try { + parts.push(JSON.stringify(context.toolInput, null, 2)) + } catch { + parts.push("(unable to serialize tool input)") + } + parts.push("```") + } + + if (context.toolResult) { + parts.push(`**Tool Result:**`) + // Truncate very long results + const maxResultLen = 2000 + const truncated = + context.toolResult.length > maxResultLen + ? context.toolResult.slice(0, maxResultLen) + "\n... (truncated)" + : context.toolResult + parts.push(truncated) + } + + if (context.completionResult) { + parts.push(`**Completion Result:**`) + parts.push(context.completionResult) + } + + if (context.conversationSummary) { + parts.push("") + parts.push("## Recent Conversation Context") + // Truncate to keep within reasonable limits + const maxContextLen = 4000 + const truncated = + context.conversationSummary.length > maxContextLen + ? context.conversationSummary.slice(0, maxContextLen) + "\n... (truncated)" + : context.conversationSummary + parts.push(truncated) + } + + parts.push("") + parts.push("## Your Task") + parts.push(hook.prompt) + + return parts.join("\n") +} + +/** + * Executes a single hook and returns the result. + */ +export async function executeHook( + hook: HookDefinition, + context: HookContext, + apiConfiguration: ProviderSettings, + hookIndex: number, +): Promise { + try { + const prompt = buildHookPrompt(hook, context) + const output = await singleCompletionHandler(apiConfiguration, prompt) + + if (!output || output.trim().length === 0) { + return null + } + + return { + output: output.trim(), + event: context.event, + hookIndex, + } + } catch (error) { + console.warn(`[HookExecutor] Failed to execute hook (${context.event}[${hookIndex}]):`, error) + return null + } +} + +/** + * Executes all matching hooks for an event and returns their combined results. + * Hooks are executed sequentially to avoid overwhelming the API. + */ +export async function executeHooks( + hooks: HookDefinition[], + context: HookContext, + apiConfiguration: ProviderSettings, +): Promise { + const results: HookResult[] = [] + + for (let i = 0; i < hooks.length; i++) { + const result = await executeHook(hooks[i], context, apiConfiguration, i) + + if (result) { + results.push(result) + } + } + + return results +} + +/** + * Formats hook results into a text block that can be injected into the + * conversation context. + */ +export function formatHookResults(results: HookResult[]): string { + if (results.length === 0) { + return "" + } + + const parts: string[] = [] + parts.push("[Hook Advisory Output]") + + for (const result of results) { + parts.push(result.output) + } + + parts.push("[End Hook Advisory Output]") + + return parts.join("\n\n") +} diff --git a/src/services/hooks/HooksManager.ts b/src/services/hooks/HooksManager.ts new file mode 100644 index 00000000000..af8305dd93e --- /dev/null +++ b/src/services/hooks/HooksManager.ts @@ -0,0 +1,153 @@ +/** + * HooksManager is responsible for loading and merging hook configurations from + * project-level (.roo/hooks.json) and global-level (~/.roo/hooks.json) files. + * + * It watches for file changes and reloads automatically. + */ + +import * as path from "path" +import * as vscode from "vscode" +import fs from "fs/promises" + +import { type HooksConfig, type HookEventName, type HookDefinition, validateHooksConfig } from "../../shared/hooks" +import { getGlobalRooDirectory } from "../roo-config" + +export class HooksManager implements vscode.Disposable { + private projectConfig: HooksConfig | null = null + private globalConfig: HooksConfig | null = null + private disposables: vscode.Disposable[] = [] + private cwd: string + + constructor(cwd: string) { + this.cwd = cwd + this.setupFileWatchers() + } + + /** + * Initializes the manager by loading both project and global configs. + */ + async initialize(): Promise { + await Promise.all([this.loadProjectConfig(), this.loadGlobalConfig()]) + } + + /** + * Returns the merged hooks for a given event. Project hooks take precedence + * and are appended after global hooks (so they run last and can override). + */ + getHooksForEvent(event: HookEventName): HookDefinition[] { + const globalHooks = this.globalConfig?.hooks[event] ?? [] + const projectHooks = this.projectConfig?.hooks[event] ?? [] + return [...globalHooks, ...projectHooks] + } + + /** + * Returns true if there are any hooks configured for the given event. + */ + hasHooksForEvent(event: HookEventName): boolean { + return this.getHooksForEvent(event).length > 0 + } + + /** + * Gets hooks that match a specific tool name for PreToolUse / PostToolUse events. + */ + getMatchingHooks(event: HookEventName, toolName?: string): HookDefinition[] { + const hooks = this.getHooksForEvent(event) + + if (!toolName) { + return hooks + } + + return hooks.filter((hook) => { + if (!hook.matcher) { + return true // No matcher means match all tools + } + + try { + const regex = new RegExp(hook.matcher) + return regex.test(toolName) + } catch { + return false // Invalid regex, skip this hook + } + }) + } + + /** + * Returns the project-level hooks.json path. + */ + getProjectHooksPath(): string { + return path.join(this.cwd, ".roo", "hooks.json") + } + + /** + * Returns the global-level hooks.json path. + */ + getGlobalHooksPath(): string { + return path.join(getGlobalRooDirectory(), "hooks.json") + } + + private async loadProjectConfig(): Promise { + this.projectConfig = await this.loadConfigFromPath(this.getProjectHooksPath()) + } + + private async loadGlobalConfig(): Promise { + this.globalConfig = await this.loadConfigFromPath(this.getGlobalHooksPath()) + } + + private async loadConfigFromPath(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + const parsed = JSON.parse(content) + const validated = validateHooksConfig(parsed) + + if (!validated) { + console.warn(`[HooksManager] Invalid hooks config at ${filePath}`) + return null + } + + return validated + } catch (error: unknown) { + // File not found is expected and not an error + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return null + } + + console.warn(`[HooksManager] Failed to load hooks config from ${filePath}:`, error) + return null + } + } + + private setupFileWatchers(): void { + // Watch project-level hooks.json + const projectPattern = new vscode.RelativePattern(this.cwd, ".roo/hooks.json") + const projectWatcher = vscode.workspace.createFileSystemWatcher(projectPattern) + + projectWatcher.onDidChange(() => this.loadProjectConfig()) + projectWatcher.onDidCreate(() => this.loadProjectConfig()) + projectWatcher.onDidDelete(() => { + this.projectConfig = null + }) + + this.disposables.push(projectWatcher) + + // Watch global-level hooks.json + const globalDir = getGlobalRooDirectory() + const globalPattern = new vscode.RelativePattern(globalDir, "hooks.json") + const globalWatcher = vscode.workspace.createFileSystemWatcher(globalPattern) + + globalWatcher.onDidChange(() => this.loadGlobalConfig()) + globalWatcher.onDidCreate(() => this.loadGlobalConfig()) + globalWatcher.onDidDelete(() => { + this.globalConfig = null + }) + + this.disposables.push(globalWatcher) + } + + dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose() + } + + this.disposables = [] + } +} diff --git a/src/services/hooks/__tests__/HookExecutor.spec.ts b/src/services/hooks/__tests__/HookExecutor.spec.ts new file mode 100644 index 00000000000..2b117e2aeb3 --- /dev/null +++ b/src/services/hooks/__tests__/HookExecutor.spec.ts @@ -0,0 +1,214 @@ +import { buildHookPrompt, executeHook, executeHooks, formatHookResults } from "../HookExecutor" +import type { HookDefinition, HookContext, HookResult } from "../../../shared/hooks" + +// Mock the singleCompletionHandler +vi.mock("../../../utils/single-completion-handler", () => ({ + singleCompletionHandler: vi.fn(), +})) + +import { singleCompletionHandler } from "../../../utils/single-completion-handler" + +const mockSingleCompletionHandler = vi.mocked(singleCompletionHandler) + +describe("buildHookPrompt", () => { + it("should build a prompt with PreToolUse context", () => { + const hook: HookDefinition = { prompt: "Check for security issues" } + const context: HookContext = { + event: "PreToolUse", + toolName: "write_to_file", + toolInput: { path: "src/main.ts", content: "console.log('test')" }, + } + + const result = buildHookPrompt(hook, context) + + expect(result).toContain("Event: PreToolUse") + expect(result).toContain("**Tool:** write_to_file") + expect(result).toContain("**Tool Input:**") + expect(result).toContain("src/main.ts") + expect(result).toContain("Check for security issues") + }) + + it("should build a prompt with PostToolUse context", () => { + const hook: HookDefinition = { prompt: "Summarize what happened" } + const context: HookContext = { + event: "PostToolUse", + toolName: "execute_command", + toolResult: "Command completed successfully", + } + + const result = buildHookPrompt(hook, context) + + expect(result).toContain("Event: PostToolUse") + expect(result).toContain("**Tool:** execute_command") + expect(result).toContain("**Tool Result:**") + expect(result).toContain("Command completed successfully") + expect(result).toContain("Summarize what happened") + }) + + it("should build a prompt with Stop context", () => { + const hook: HookDefinition = { prompt: "Review the result" } + const context: HookContext = { + event: "Stop", + completionResult: "Task completed successfully", + } + + const result = buildHookPrompt(hook, context) + + expect(result).toContain("Event: Stop") + expect(result).toContain("**Completion Result:**") + expect(result).toContain("Task completed successfully") + expect(result).toContain("Review the result") + }) + + it("should truncate long tool results", () => { + const hook: HookDefinition = { prompt: "Summarize" } + const longResult = "x".repeat(3000) + const context: HookContext = { + event: "PostToolUse", + toolName: "read_file", + toolResult: longResult, + } + + const result = buildHookPrompt(hook, context) + + expect(result).toContain("... (truncated)") + expect(result.length).toBeLessThan(longResult.length) + }) + + it("should truncate long conversation summaries", () => { + const hook: HookDefinition = { prompt: "Analyze" } + const longSummary = "y".repeat(5000) + const context: HookContext = { + event: "PreToolUse", + conversationSummary: longSummary, + } + + const result = buildHookPrompt(hook, context) + + expect(result).toContain("... (truncated)") + }) + + it("should include advisory-only role description", () => { + const hook: HookDefinition = { prompt: "test" } + const context: HookContext = { event: "PreToolUse" } + + const result = buildHookPrompt(hook, context) + + expect(result).toContain("advisory only") + expect(result).toContain("cannot use tools") + }) +}) + +describe("executeHook", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should execute a hook and return the result", async () => { + mockSingleCompletionHandler.mockResolvedValue("This looks safe to proceed.") + + const hook: HookDefinition = { prompt: "Check security" } + const context: HookContext = { event: "PreToolUse", toolName: "write_to_file" } + const mockConfig = { apiProvider: "anthropic" } as any + + const result = await executeHook(hook, context, mockConfig, 0) + + expect(result).not.toBeNull() + expect(result!.output).toBe("This looks safe to proceed.") + expect(result!.event).toBe("PreToolUse") + expect(result!.hookIndex).toBe(0) + expect(mockSingleCompletionHandler).toHaveBeenCalledWith(mockConfig, expect.any(String)) + }) + + it("should return null for empty output", async () => { + mockSingleCompletionHandler.mockResolvedValue("") + + const hook: HookDefinition = { prompt: "Check" } + const context: HookContext = { event: "PreToolUse" } + + const result = await executeHook(hook, context, {} as any, 0) + + expect(result).toBeNull() + }) + + it("should return null on error", async () => { + mockSingleCompletionHandler.mockRejectedValue(new Error("API Error")) + + const hook: HookDefinition = { prompt: "Check" } + const context: HookContext = { event: "PreToolUse" } + + const result = await executeHook(hook, context, {} as any, 0) + + expect(result).toBeNull() + }) +}) + +describe("executeHooks", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should execute multiple hooks sequentially", async () => { + mockSingleCompletionHandler.mockResolvedValueOnce("Result 1").mockResolvedValueOnce("Result 2") + + const hooks: HookDefinition[] = [{ prompt: "Hook 1" }, { prompt: "Hook 2" }] + const context: HookContext = { event: "PreToolUse", toolName: "write_to_file" } + + const results = await executeHooks(hooks, context, {} as any) + + expect(results).toHaveLength(2) + expect(results[0].output).toBe("Result 1") + expect(results[1].output).toBe("Result 2") + expect(mockSingleCompletionHandler).toHaveBeenCalledTimes(2) + }) + + it("should skip hooks that return null", async () => { + mockSingleCompletionHandler + .mockResolvedValueOnce("Result 1") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("Result 3") + + const hooks: HookDefinition[] = [{ prompt: "Hook 1" }, { prompt: "Hook 2" }, { prompt: "Hook 3" }] + const context: HookContext = { event: "PreToolUse" } + + const results = await executeHooks(hooks, context, {} as any) + + expect(results).toHaveLength(2) + expect(results[0].output).toBe("Result 1") + expect(results[1].output).toBe("Result 3") + }) + + it("should return empty array for no hooks", async () => { + const results = await executeHooks([], { event: "PreToolUse" }, {} as any) + expect(results).toHaveLength(0) + expect(mockSingleCompletionHandler).not.toHaveBeenCalled() + }) +}) + +describe("formatHookResults", () => { + it("should return empty string for no results", () => { + expect(formatHookResults([])).toBe("") + }) + + it("should format single result", () => { + const results: HookResult[] = [{ output: "Advisory: looks good", event: "PreToolUse", hookIndex: 0 }] + + const formatted = formatHookResults(results) + + expect(formatted).toContain("[Hook Advisory Output]") + expect(formatted).toContain("Advisory: looks good") + expect(formatted).toContain("[End Hook Advisory Output]") + }) + + it("should format multiple results", () => { + const results: HookResult[] = [ + { output: "Result 1", event: "PreToolUse", hookIndex: 0 }, + { output: "Result 2", event: "PreToolUse", hookIndex: 1 }, + ] + + const formatted = formatHookResults(results) + + expect(formatted).toContain("Result 1") + expect(formatted).toContain("Result 2") + }) +}) diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts new file mode 100644 index 00000000000..7d739ec56d2 --- /dev/null +++ b/src/services/hooks/index.ts @@ -0,0 +1,2 @@ +export { HooksManager } from "./HooksManager" +export { executeHooks, formatHookResults, buildHookPrompt } from "./HookExecutor" diff --git a/src/shared/__tests__/hooks.spec.ts b/src/shared/__tests__/hooks.spec.ts new file mode 100644 index 00000000000..22611b8218b --- /dev/null +++ b/src/shared/__tests__/hooks.spec.ts @@ -0,0 +1,180 @@ +import { validateHooksConfig, type HooksConfig } from "../hooks" + +describe("validateHooksConfig", () => { + it("should return null for null/undefined input", () => { + expect(validateHooksConfig(null)).toBeNull() + expect(validateHooksConfig(undefined)).toBeNull() + }) + + it("should return null for non-object input", () => { + expect(validateHooksConfig("string")).toBeNull() + expect(validateHooksConfig(123)).toBeNull() + expect(validateHooksConfig(true)).toBeNull() + }) + + it("should return null when hooks key is missing", () => { + expect(validateHooksConfig({})).toBeNull() + expect(validateHooksConfig({ other: "data" })).toBeNull() + }) + + it("should return null when hooks is not an object", () => { + expect(validateHooksConfig({ hooks: "string" })).toBeNull() + expect(validateHooksConfig({ hooks: 123 })).toBeNull() + }) + + it("should accept empty hooks object", () => { + const result = validateHooksConfig({ hooks: {} }) + expect(result).toEqual({ hooks: {} }) + }) + + it("should accept valid PreToolUse hooks", () => { + const config = { + hooks: { + PreToolUse: [ + { + matcher: "write_to_file|apply_diff", + prompt: "Review this code change for security issues", + }, + ], + }, + } + const result = validateHooksConfig(config) + expect(result).not.toBeNull() + expect(result!.hooks.PreToolUse).toHaveLength(1) + expect(result!.hooks.PreToolUse![0].matcher).toBe("write_to_file|apply_diff") + expect(result!.hooks.PreToolUse![0].prompt).toBe("Review this code change for security issues") + }) + + it("should accept hooks without matcher (matches all tools)", () => { + const config = { + hooks: { + PostToolUse: [ + { + prompt: "Summarize what happened", + }, + ], + }, + } + const result = validateHooksConfig(config) + expect(result).not.toBeNull() + expect(result!.hooks.PostToolUse).toHaveLength(1) + expect(result!.hooks.PostToolUse![0].matcher).toBeUndefined() + }) + + it("should accept valid Stop hooks", () => { + const config = { + hooks: { + Stop: [ + { + prompt: "Review the final result", + }, + ], + }, + } + const result = validateHooksConfig(config) + expect(result).not.toBeNull() + expect(result!.hooks.Stop).toHaveLength(1) + }) + + it("should accept multiple hooks per event", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: "Hook 1" }, { prompt: "Hook 2" }, { prompt: "Hook 3" }], + }, + } + const result = validateHooksConfig(config) + expect(result).not.toBeNull() + expect(result!.hooks.PreToolUse).toHaveLength(3) + }) + + it("should accept multiple event types", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: "Pre hook" }], + PostToolUse: [{ prompt: "Post hook" }], + Stop: [{ prompt: "Stop hook" }], + }, + } + const result = validateHooksConfig(config) + expect(result).not.toBeNull() + expect(result!.hooks.PreToolUse).toHaveLength(1) + expect(result!.hooks.PostToolUse).toHaveLength(1) + expect(result!.hooks.Stop).toHaveLength(1) + }) + + it("should return null for empty prompt", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: "" }], + }, + } + expect(validateHooksConfig(config)).toBeNull() + }) + + it("should return null for whitespace-only prompt", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: " " }], + }, + } + expect(validateHooksConfig(config)).toBeNull() + }) + + it("should return null for non-string prompt", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: 123 }], + }, + } + expect(validateHooksConfig(config)).toBeNull() + }) + + it("should return null for non-string matcher", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: "test", matcher: 123 }], + }, + } + expect(validateHooksConfig(config)).toBeNull() + }) + + it("should return null for invalid regex matcher", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: "test", matcher: "[invalid" }], + }, + } + expect(validateHooksConfig(config)).toBeNull() + }) + + it("should return null for non-array hook definitions", () => { + const config = { + hooks: { + PreToolUse: "not an array", + }, + } + expect(validateHooksConfig(config)).toBeNull() + }) + + it("should return null for non-object hook definition items", () => { + const config = { + hooks: { + PreToolUse: ["string item"], + }, + } + expect(validateHooksConfig(config)).toBeNull() + }) + + it("should ignore unknown event names", () => { + const config = { + hooks: { + PreToolUse: [{ prompt: "valid" }], + UnknownEvent: [{ prompt: "ignored" }], + }, + } + const result = validateHooksConfig(config) + expect(result).not.toBeNull() + expect(result!.hooks.PreToolUse).toHaveLength(1) + expect((result!.hooks as any).UnknownEvent).toBeUndefined() + }) +}) diff --git a/src/shared/hooks.ts b/src/shared/hooks.ts new file mode 100644 index 00000000000..60b09434c30 --- /dev/null +++ b/src/shared/hooks.ts @@ -0,0 +1,150 @@ +/** + * Prompt-based hooks configuration and types. + * + * Hooks allow a smaller/different model to step in at specific agent lifecycle + * events and provide read-only advisory output that gets injected into the + * conversation context. + * + * Configuration lives in `.roo/hooks.json` (project) and `~/.roo/hooks.json` + * (global user settings). The format is intentionally compatible with Claude + * Code's hooks format for interoperability. + */ + +/** + * Hook event names that map to agent lifecycle points. + * + * - PreToolUse: Fires after a tool call is validated but before execution. + * - PostToolUse: Fires after a tool call completes and its result is available. + * - Stop: Fires when `attempt_completion` is invoked, before the task ends. + */ +export type HookEventName = "PreToolUse" | "PostToolUse" | "Stop" + +/** + * A single hook definition inside a hooks.json file. + */ +export interface HookDefinition { + /** + * Optional regex pattern to match against the tool name (for PreToolUse / PostToolUse). + * If omitted the hook fires for every tool. Ignored for Stop events. + */ + matcher?: string + + /** + * The prompt to send to the hook model. The system will prepend relevant + * conversation context automatically. + */ + prompt: string +} + +/** + * Top-level structure of a hooks.json configuration file. + */ +export interface HooksConfig { + hooks: Partial> +} + +/** + * Context passed to a hook prompt so the model has enough information + * to produce a useful advisory response. + */ +export interface HookContext { + /** The event that triggered this hook. */ + event: HookEventName + + /** Name of the tool involved (PreToolUse / PostToolUse only). */ + toolName?: string + + /** The tool's input parameters (PreToolUse only). */ + toolInput?: Record + + /** The tool's result text (PostToolUse only). */ + toolResult?: string + + /** The completion result text (Stop only). */ + completionResult?: string + + /** Recent conversation messages (trimmed for context window). */ + conversationSummary?: string +} + +/** + * Result returned after executing a hook prompt. + */ +export interface HookResult { + /** The hook's advisory output text. */ + output: string + + /** The event that produced this result. */ + event: HookEventName + + /** Which hook definition (by index) produced this result. */ + hookIndex: number +} + +/** + * Validates a parsed object against the expected HooksConfig shape. + * Returns the validated config or null if invalid. + */ +export function validateHooksConfig(obj: unknown): HooksConfig | null { + if (!obj || typeof obj !== "object") { + return null + } + + const candidate = obj as Record + + if (!candidate.hooks || typeof candidate.hooks !== "object") { + return null + } + + const hooks = candidate.hooks as Record + const validEvents: HookEventName[] = ["PreToolUse", "PostToolUse", "Stop"] + const result: HooksConfig = { hooks: {} } + + for (const event of validEvents) { + const defs = hooks[event] + if (defs === undefined) { + continue + } + + if (!Array.isArray(defs)) { + return null + } + + const validDefs: HookDefinition[] = [] + + for (const def of defs) { + if (!def || typeof def !== "object") { + return null + } + + const d = def as Record + + if (typeof d.prompt !== "string" || d.prompt.trim().length === 0) { + return null + } + + const hookDef: HookDefinition = { prompt: d.prompt } + + if (d.matcher !== undefined) { + if (typeof d.matcher !== "string") { + return null + } + + // Validate the regex + try { + new RegExp(d.matcher) + } catch { + return null + } + + hookDef.matcher = d.matcher + } + + validDefs.push(hookDef) + } + + result.hooks[event] = validDefs + } + + return result +}