diff --git a/notepad.md b/notepad.md index de32730..daee604 100644 --- a/notepad.md +++ b/notepad.md @@ -739,3 +739,59 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025 --- +## [2025-12-09 18:08] - Task 8: Factory 생성 + 통합 + +### DISCOVERED ISSUES +- None - final integration task with well-defined hook executors from previous tasks + +### IMPLEMENTATION DECISIONS +- Created `src/hooks/claude-code-hooks/index.ts` (146 lines) with createClaudeCodeHooksHook() factory +- Factory returns hook handler object with 3 handlers: + * `tool.execute.before`: Executes executePreToolUseHooks() + - Loads config dynamically (async) on each invocation + - Maps OpenCode input → PreToolUseContext + - Caches tool input for PostToolUse + - Handles deny/ask decisions (deny throws error, ask logs warning) + * `tool.execute.after`: Executes executePostToolUseHooks() + - Retrieves cached tool input via getToolInput() + - Maps OpenCode input → PostToolUseContext with client wrapper + - Appends hook message to output if provided + - Throws error if block decision returned + * `event`: Executes executeStopHooks() for session.idle + - Filters event.type === "session.idle" + - Maps OpenCode event → StopContext + - Injects prompt via ctx.client.session.prompt() if injectPrompt returned +- Updated `src/hooks/index.ts`: Added createClaudeCodeHooksHook export +- Updated `src/index.ts`: + * Imported createClaudeCodeHooksHook + * Created claudeCodeHooks instance + * Registered handlers in tool.execute.before, tool.execute.after, event hooks + * Claude hooks run FIRST in execution order (before other hooks) +- Config loading: Async loadClaudeHooksConfig() and loadPluginExtendedConfig() called in each handler (not cached) +- Transcript path: Uses getTranscriptPath() function (not buildTranscriptPath which doesn't exist) + +### PROBLEMS FOR NEXT TASKS +- None - this is the final task (Task 8) +- All Claude Code Hooks now integrated into oh-my-opencode plugin system + +### VERIFICATION RESULTS +- Ran: `bun run typecheck` → exit 0, no errors +- Ran: `bun run build` → exit 0, successful build +- Files modified: + * src/hooks/claude-code-hooks/index.ts (created) + * src/hooks/index.ts (export added) + * src/index.ts (hook registration) +- Hook handler registration verified: claudeCodeHooks handlers called in all 3 hook points +- Execution order verified: Claude hooks run before existing hooks in tool.execute.* + +### LEARNINGS +- OpenCode Plugin API: Factory pattern createXxxHook(ctx: PluginInput) → handlers object +- OpenCode does NOT have chat.params hook → UserPromptSubmit not implemented in factory +- Config loading must be async → call loadClaudeHooksConfig() in each handler, not once during initialization +- Tool input cache is module-level state → cacheToolInput/getToolInput work across handlers +- Stop hook only triggers on session.idle event → filter event.type +- Import path: getTranscriptPath (exists), not buildTranscriptPath (doesn't exist) + +소요 시간: ~6분 + +--- diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts new file mode 100644 index 0000000..904f596 --- /dev/null +++ b/src/hooks/claude-code-hooks/index.ts @@ -0,0 +1,145 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "./config" +import { loadPluginExtendedConfig } from "./config-loader" +import { + executePreToolUseHooks, + type PreToolUseContext, +} from "./pre-tool-use" +import { + executePostToolUseHooks, + type PostToolUseContext, + type PostToolUseClient, +} from "./post-tool-use" +import { + executeStopHooks, + type StopContext, +} from "./stop" +import { cacheToolInput, getToolInput } from "./tool-input-cache" +import { getTranscriptPath } from "./transcript" +import { log } from "../../shared" + +export function createClaudeCodeHooksHook(ctx: PluginInput) { + + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record } + ): Promise => { + try { + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const preCtx: PreToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: output.args, + cwd: ctx.directory, + transcriptPath: getTranscriptPath(input.sessionID), + toolUseId: input.callID, + } + + cacheToolInput(input.sessionID, input.tool, input.callID, output.args) + + const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) + + if (result.decision === "deny") { + throw new Error(result.reason || "Tool execution denied by PreToolUse hook") + } + + if (result.decision === "ask") { + log(`[Claude Hooks] PreToolUse hook returned "ask" decision, but OpenCode doesn't support interactive prompts. Allowing by default.`) + } + + if (result.modifiedInput) { + output.args = result.modifiedInput + } + } catch (error) { + log(`[Claude Hooks] PreToolUse error:`, error) + throw error + } + }, + + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ): Promise => { + try { + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} + + const postClient: PostToolUseClient = { + session: { + messages: (opts) => ctx.client.session.messages(opts), + }, + } + + const postCtx: PostToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: cachedInput, + toolOutput: { + title: output.title, + output: output.output, + metadata: output.metadata, + }, + cwd: ctx.directory, + transcriptPath: getTranscriptPath(input.sessionID), + toolUseId: input.callID, + client: postClient, + } + + const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) + + if (result.message) { + output.output += `\n\n${result.message}` + } + + if (result.block) { + throw new Error(result.reason || "Tool execution blocked by PostToolUse hook") + } + } catch (error) { + log(`[Claude Hooks] PostToolUse error:`, error) + } + }, + + event: async (input: { event: { type: string; properties?: unknown } }) => { + const { event } = input + + if (event.type === "session.idle") { + try { + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + + if (!sessionID) return + + const stopCtx: StopContext = { + sessionId: sessionID, + cwd: ctx.directory, + transcriptPath: getTranscriptPath(sessionID), + } + + const result = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) + + if (result.injectPrompt) { + await ctx.client.session.prompt({ + path: { id: sessionID }, + body: { + parts: [{ type: "text", text: result.injectPrompt }], + }, + query: { directory: ctx.directory }, + }).catch((err) => { + log(`[Claude Hooks] Failed to inject prompt from Stop hook:`, err) + }) + } + } catch (error) { + log(`[Claude Hooks] Stop hook error:`, error) + } + } + }, + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e29250c..889ed69 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,3 +8,4 @@ export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector"; export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector"; export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact"; export { createThinkModeHook } from "./think-mode"; +export { createClaudeCodeHooksHook } from "./claude-code-hooks"; diff --git a/src/index.ts b/src/index.ts index 0bc5339..5998f06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { createDirectoryAgentsInjectorHook, createEmptyTaskResponseDetectorHook, createThinkModeHook, + createClaudeCodeHooksHook, } from "./hooks"; import { loadUserCommands, @@ -76,6 +77,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx); const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx); const thinkMode = createThinkModeHook(); + const claudeCodeHooks = createClaudeCodeHooksHook(ctx); updateTerminalTitle({ sessionId: "main" }); @@ -129,6 +131,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }, event: async (input) => { + await claudeCodeHooks.event(input); await todoContinuationEnforcer(input); await contextWindowMonitor.event(input); await directoryAgentsInjector.event(input); @@ -229,6 +232,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }, "tool.execute.before": async (input, output) => { + await claudeCodeHooks["tool.execute.before"](input, output); await commentChecker["tool.execute.before"](input, output); if (input.sessionID === getMainSessionID()) { @@ -243,6 +247,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }, "tool.execute.after": async (input, output) => { + await claudeCodeHooks["tool.execute.after"](input, output); await grepOutputTruncator["tool.execute.after"](input, output); await contextWindowMonitor["tool.execute.after"](input, output); await commentChecker["tool.execute.after"](input, output);