import { isEffortLevel } from "../../contracts/effort.ts"; import type { EffortLevel, HarnessEvent, HarnessEventKind } from "../../contracts/index.ts"; import { sanitizeSegment } from "../../platform/sanitize.ts"; export const EVENT_KIND_BY_HOOK: Record = { SessionStart: "session.start", SessionEnd: "session.end", UserPromptSubmit: "prompt.submit", PostToolUseFailure: "tool.failure", SubagentStart: "subagent.start", SubagentStop: "subagent.stop", Stop: "stop", PreCompact: "compact.before", MessageDisplay: "response.after", }; const MCP_TOOL_NAME = /^mcp__/; type ToolNameFanOutRule = { match: RegExp | string; kind: HarnessEventKind }; // why: PreToolUse fans out by tool_name — Claude has no dedicated shell/MCP/read event. export const PRE_TOOL_USE_FAN_OUT: readonly ToolNameFanOutRule[] = [ { match: "Bash", kind: "shell.before" }, { match: MCP_TOOL_NAME, kind: "mcp.before" }, { match: "Read", kind: "read.before" }, ]; // why: PostToolUse fans out the same way, for the after-half of each of those tool classes. export const POST_TOOL_USE_FAN_OUT: readonly ToolNameFanOutRule[] = [ { match: "Bash", kind: "shell.after" }, { match: MCP_TOOL_NAME, kind: "mcp.after" }, { match: "Edit", kind: "edit.after" }, { match: "Write", kind: "edit.after" }, ]; function matchFanOut( table: readonly ToolNameFanOutRule[], toolName: string | undefined, fallback: HarnessEventKind, ): HarnessEventKind { if (toolName === undefined) { return fallback; } for (const rule of table) { const matched = typeof rule.match === "string" ? rule.match === toolName : rule.match.test(toolName); if (matched) { return rule.kind; } } return fallback; } function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function asRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function asStatus(value: unknown): "completed" | "aborted" | "error" | undefined { return value === "completed" || value === "aborted" || value === "error" ? value : undefined; } function sessionKeyFor(raw: Record): string { const seed = asString(raw.session_id) ?? "default"; return `claude-${sanitizeSegment(seed)}`; } function projectDirFor(raw: Record): string { const envDir = process.env.CLAUDE_PROJECT_DIR; if (envDir) { return envDir; } const cwd = asString(raw.cwd); if (cwd) { return cwd; } return process.cwd(); } function effortFor(raw: Record): EffortLevel | undefined { const effort = asRecord(raw.effort); const level = effort?.level; return isEffortLevel(level) ? level : undefined; } function preToolUseKind(toolName: string | undefined): HarnessEventKind { return matchFanOut(PRE_TOOL_USE_FAN_OUT, toolName, "tool.before"); } function postToolUseKind(toolName: string | undefined): HarnessEventKind { return matchFanOut(POST_TOOL_USE_FAN_OUT, toolName, "tool.after"); } /** Never throws on a malformed payload — returns null instead. */ export function claudeToEvent(raw: Record): HarnessEvent | null { const hookEventName = asString(raw.hook_event_name); if (!hookEventName) { return null; } const toolName = asString(raw.tool_name); const toolInput = asRecord(raw.tool_input); let eventKind: HarnessEventKind | undefined; if (hookEventName === "PreToolUse") { eventKind = preToolUseKind(toolName); } else if (hookEventName === "PostToolUse") { eventKind = postToolUseKind(toolName); } else { eventKind = EVENT_KIND_BY_HOOK[hookEventName]; } if (!eventKind) { return null; } const event: HarnessEvent = { provider: "claude", event: eventKind, sessionKey: sessionKeyFor(raw), projectDir: projectDirFor(raw), raw, }; const permissionMode = asString(raw.permission_mode); if (permissionMode) { event.permissionMode = permissionMode; } // why: `cwd` is a common field on every Claude hook payload, and the host docs confirm it tracks the // agent into a worktree or after a `cd` — unlike `projectDir`, which stays at the session's original // root ([/decisions/ad-114.md](/decisions/ad-114.md)). const cwd = asString(raw.cwd); if (cwd) { event.cwd = cwd; } const isSpawnEvent = eventKind === "subagent.start" || eventKind === "subagent.stop"; const model = isSpawnEvent ? undefined : asString(raw.model); if (model) { event.model = model; } const effort = effortFor(raw); if (effort) { event.effort = effort; } const contextUsagePercent = asNumber(raw.context_usage_percent); if (contextUsagePercent !== undefined) { event.contextUsagePercent = contextUsagePercent; } const transcriptPath = asString(raw.transcript_path); if (transcriptPath) { event.transcriptPath = transcriptPath; } if (!isSpawnEvent) { const callerAgentType = asString(raw.agent_type); if (callerAgentType) { event.subagentType = callerAgentType; } } switch (eventKind) { case "prompt.submit": { const text = asString(raw.prompt); if (text !== undefined) { event.text = text; } break; } case "response.after": { const text = asString(raw.text); if (text !== undefined) { event.text = text; } break; } case "shell.before": case "shell.after": { const command = toolInput ? asString(toolInput.command) : undefined; if (command !== undefined) { event.command = command; } break; } case "mcp.before": case "mcp.after": { if (toolName) { event.toolName = toolName; } if (toolInput) { event.toolInput = toolInput; } break; } case "read.before": { const filePath = toolInput ? asString(toolInput.file_path) : undefined; if (filePath !== undefined) { event.filePath = filePath; } break; } case "edit.after": { if (toolName) { event.toolName = toolName; } const filePath = toolInput ? asString(toolInput.file_path) : undefined; if (filePath !== undefined) { event.filePath = filePath; } break; } case "tool.before": case "tool.after": case "tool.failure": { if (toolName) { event.toolName = toolName; } // why: an object here, unlike the two string fields Cursor uses. Serialising is the translation this layer // exists to do, and core reads one shape ([/decisions/ad-004.md](/decisions/ad-004.md)). const toolOutput = raw.tool_response; if (toolOutput !== undefined && toolOutput !== null) { event.toolOutput = typeof toolOutput === "string" ? toolOutput : JSON.stringify(toolOutput); } if (toolInput) { event.toolInput = toolInput; } const filePath = toolInput ? asString(toolInput.file_path) : undefined; if (filePath !== undefined) { event.filePath = filePath; } const spawnSubagentType = toolInput ? asString(toolInput.subagent_type) : undefined; if (spawnSubagentType) { event.spawnSubagentType = spawnSubagentType; } // why the name too: an addressable spawn carries one, and it is the value the host echoes back as // `agent_type` at the stop. Without it here, the stop cannot be resolved to the type the spawn declared // ([/decisions/ad-104.md](/decisions/ad-104.md)). const spawnAgentLabel = toolInput ? asString(toolInput.name) : undefined; if (spawnAgentLabel) { event.spawnAgentLabel = spawnAgentLabel; } const spawnModel = toolInput ? asString(toolInput.model) : undefined; if (spawnModel) { event.spawnModel = spawnModel; } if (toolName === "Write") { const proposedContent = toolInput ? asString(toolInput.content) : undefined; if (proposedContent !== undefined) { event.proposedContent = proposedContent; } } if (toolName === "Edit") { const proposedContent = toolInput ? asString(toolInput.new_string) : undefined; if (proposedContent !== undefined) { event.proposedContent = proposedContent; } const proposedOldContent = toolInput ? asString(toolInput.old_string) : undefined; if (proposedOldContent !== undefined) { event.proposedOldContent = proposedOldContent; } } break; } case "subagent.start": case "subagent.stop": { /** * hazard: `agent_type` was read here as the type. The hooks reference describes it as the agent's *name*, * and when a spawn is given a `name` the host puts that name in it — measured on a real payload: * `subagent_type: "the-judge"` at the spawn, `agent_type: "judge-harness-rule"` inside the child. So the * value a rule had to match was the one the gated agent chose, which broke a legitimate review and made the * proof forgeable ([/decisions/ad-104.md](/decisions/ad-104.md)). * * invariant: only a field that means the declared type lands in `spawnSubagentType`. The host's label goes * to `spawnAgentLabel`, and the correlation with the spawn resolves it. */ const spawnSubagentType = asString(raw.subagent_type) ?? (toolInput ? asString(toolInput.subagent_type) : undefined); if (spawnSubagentType) { event.spawnSubagentType = spawnSubagentType; } const spawnAgentLabel = asString(raw.agent_type) ?? (toolInput ? asString(toolInput.name) : undefined); if (spawnAgentLabel) { event.spawnAgentLabel = spawnAgentLabel; } const spawnModel = (toolInput ? asString(toolInput.model) : undefined) ?? asString(raw.model); if (spawnModel) { event.spawnModel = spawnModel; } break; } case "stop": { const status = asStatus(raw.status); if (status) { event.status = status; } break; } default: break; } return event; }