feat: add prompt-based hooks for agent lifecycle events

Implements prompt-based hooks that allow a smaller/different model to step in
at specific agent lifecycle events and provide read-only advisory output.

Hook events supported:
- PreToolUse: fires before tool execution (with optional tool name matcher)
- PostToolUse: fires after tool execution completes
- Stop: fires when attempt_completion is invoked

Configuration via .roo/hooks.json (project) and ~/.roo/hooks.json (global),
using a Claude Code-compatible format. Hooks are read-only (no tool access)
and receive conversation context. Uses the active profile API configuration.

New files:
- src/shared/hooks.ts: types, interfaces, and validation
- src/services/hooks/HooksManager.ts: config loading with file watchers
- src/services/hooks/HookExecutor.ts: hook prompt building and execution
- src/shared/__tests__/hooks.spec.ts: 18 validation tests
- src/services/hooks/__tests__/HookExecutor.spec.ts: 15 executor tests

Modified files:
- packages/types/src/message.ts: added hook_output ClineSay type
- src/core/webview/ClineProvider.ts: HooksManager initialization
- src/core/assistant-message/presentAssistantMessage.ts: PreToolUse/PostToolUse hooks
- src/core/tools/AttemptCompletionTool.ts: Stop hook

Addresses #11504
This commit is contained in:
Roo Code 2026-02-17 17:32:52 +00:00
parent dc243e4cf9
commit d8b53b814a
10 changed files with 939 additions and 0 deletions

View file

@ -170,6 +170,7 @@ export const clineSays = [
"user_edit_todos",
"too_many_tools_warning",
"tool",
"hook_output",
] as const
export const clineSaySchema = z.enum(clineSays)

View file

@ -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"
@ -675,6 +677,30 @@ export async function presentAssistantMessage(cline: Task) {
}
}
// === PreToolUse Hook ===
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 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 +943,38 @@ 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) {
// 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 : ""
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
}
}

View file

@ -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,25 @@ 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 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

View file

@ -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 <most> 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

View file

@ -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<HookResult | null> {
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<HookResult[]> {
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")
}

View file

@ -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<void> {
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<void> {
this.projectConfig = await this.loadConfigFromPath(this.getProjectHooksPath())
}
private async loadGlobalConfig(): Promise<void> {
this.globalConfig = await this.loadConfigFromPath(this.getGlobalHooksPath())
}
private async loadConfigFromPath(filePath: string): Promise<HooksConfig | null> {
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 = []
}
}

View file

@ -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")
})
})

View file

@ -0,0 +1,2 @@
export { HooksManager } from "./HooksManager"
export { executeHooks, formatHookResults, buildHookPrompt } from "./HookExecutor"

View file

@ -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()
})
})

150
src/shared/hooks.ts Normal file
View file

@ -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<Record<HookEventName, HookDefinition[]>>
}
/**
* 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<string, unknown>
/** 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<string, unknown>
if (!candidate.hooks || typeof candidate.hooks !== "object") {
return null
}
const hooks = candidate.hooks as Record<string, unknown>
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<string, unknown>
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
}