diff --git a/.harness/src/skills/harness-hook/SKILL.md b/.harness/src/skills/harness-hook/SKILL.md index 18b3196..262833a 100644 --- a/.harness/src/skills/harness-hook/SKILL.md +++ b/.harness/src/skills/harness-hook/SKILL.md @@ -51,17 +51,17 @@ Recommendation: start with `"best_effort"` during development, switch to `"stric | Canonical event | Claude Code | GitHub Copilot | OpenAI Codex | Cursor | |---|---|---|---|---| -| `session_start` | Yes (`SessionStart`) | Yes (`sessionStart`) | No | Yes (`sessionStart`) | +| `session_start` | Yes (`SessionStart`) | Yes (`sessionStart`) | Yes (`SessionStart`) | Yes (`sessionStart`) | | `session_end` | Yes (`SessionEnd`) | Yes (`sessionEnd`) | No | Yes (`sessionEnd`) | -| `prompt_submit` | Yes (`UserPromptSubmit`) | Yes (`userPromptSubmitted`) | No | Yes (`beforeSubmitPrompt`) | -| `pre_tool_use` | Yes (`PreToolUse`) | Yes (`preToolUse`) | No | Yes (`preToolUse`) | -| `permission_request` | Yes (`PermissionRequest`) | No | No | No | -| `post_tool_use` | Yes (`PostToolUse`) | Yes (`postToolUse`) | No | Yes (`postToolUse`) | +| `prompt_submit` | Yes (`UserPromptSubmit`) | Yes (`userPromptSubmitted`) | Yes (`UserPromptSubmit`) | Yes (`beforeSubmitPrompt`) | +| `pre_tool_use` | Yes (`PreToolUse`) | Yes (`preToolUse`) | Yes (`PreToolUse`) | Yes (`preToolUse`) | +| `permission_request` | Yes (`PermissionRequest`) | No | Yes (`PermissionRequest`) | No | +| `post_tool_use` | Yes (`PostToolUse`) | Yes (`postToolUse`) | Yes (`PostToolUse`) | Yes (`postToolUse`) | | `post_tool_failure` | Yes (`PostToolUseFailure`) | No | No | Yes (`postToolUseFailure`) | | `notification` | Yes (`Notification`) | No | No | No | | `subagent_start` | Yes (`SubagentStart`) | No | No | Yes (`subagentStart`) | | `subagent_stop` | Yes (`SubagentStop`) | No | No | Yes (`subagentStop`) | -| `stop` | Yes (`Stop`) | No | No | Yes (`stop`) | +| `stop` | Yes (`Stop`) | No | Yes (`Stop`) | Yes (`stop`) | | `stop_failure` | Yes (`StopFailure`) | No | No | No | | `teammate_idle` | Yes (`TeammateIdle`) | No | No | No | | `task_completed` | Yes (`TaskCompleted`) | No | No | No | @@ -135,21 +135,39 @@ Recommendation: start with `"best_effort"` during development, switch to `"stric ### OpenAI Codex CLI -- **Output file:** `.codex/config.toml` (key: `notify = [...]`) -- **Supported canonical events:** `turn_complete` only -- **Handler types:** both `notify` and `command` handlers are accepted — both normalize to a TOML notify command array -- **Matcher:** NOT supported -- **Normalization rules:** +- **Output file:** `.codex/config.toml` (keys: inline `[hooks]`, `[features] hooks = true`, and legacy `notify = [...]`) +- **Supported canonical lifecycle events:** `session_start`, `prompt_submit`, `pre_tool_use`, `permission_request`, `post_tool_use`, `stop` +- **Legacy notification event:** `turn_complete` renders to top-level `notify = [...]` +- **Handler types:** lifecycle hooks accept `command`; `turn_complete` accepts `notify` and `command`, both normalized to a TOML notify command array +- **Matcher:** supported for `session_start`, `pre_tool_use`, `permission_request`, and `post_tool_use`; unsupported matcher usage fails in `"strict"` mode +- **Command options:** `timeout`/`timeoutSec` render as `timeout`; `statusMessage` is supported; `cwd` and `env` are unsupported +- **Notify normalization rules:** - `notify` handler: `command` field used directly; string values are wrapped as `["sh", "-lc", ""]`; arrays pass through unchanged - `command` handler: first available field among `command`, `bash`, `linux`, `osx`, `powershell`, `windows` is selected and wrapped the same way - **Conflict rule:** only one notify command is allowed across all enabled hook entities. If two hooks produce different notify commands, `apply` fails with `HOOK_NOTIFY_CONFLICT`. -- **Output shape:** +- **Lifecycle output shape:** + +```toml +[features] +hooks = true + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 scripts/check_bash.py" +timeout = 30 +statusMessage = "Checking Bash command" +``` + +- **Notify output shape:** ```toml notify = ["python3", "scripts/on_turn_complete.py"] ``` -- **Official docs:** https://developers.openai.com/codex/config-reference +- **Official docs:** https://developers.openai.com/codex/hooks ### Cursor @@ -201,14 +219,16 @@ notify = ["python3", "scripts/on_turn_complete.py"] "matcher": "Bash", "cwd": ".", "env": { "MY_VAR": "value" }, - "timeoutSec": 30 + "timeoutSec": 30, + "statusMessage": "Checking command" } ``` - At least one of `command`, `bash`, `linux`, `osx`, `windows`, `powershell` is required. -- `matcher` is Claude-only; ignored/errors for Copilot (strict mode error), not applicable for Codex. +- `matcher` support is provider/event-dependent. Codex supports it on `session_start`, `pre_tool_use`, `permission_request`, and `post_tool_use`; Copilot does not support it. - `env` values must all be strings. - `timeoutSec` (or `timeout`) must be a positive number. +- `statusMessage` is Codex lifecycle-hook only. ### `notify` handler (Codex only) @@ -288,7 +308,42 @@ Claude output fragment: } ``` -### 3) Codex turn-complete notification +### 3) Codex pre-tool guard + +```json +{ + "mode": "strict", + "events": { + "pre_tool_use": [ + { + "type": "command", + "matcher": "^Bash$", + "command": "python3 scripts/check_bash.py", + "timeout": 30, + "statusMessage": "Checking Bash command" + } + ] + } +} +``` + +Codex output in `.codex/config.toml`: + +```toml +[features] +hooks = true + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 scripts/check_bash.py" +timeout = 30 +statusMessage = "Checking Bash command" +``` + +### 4) Codex turn-complete notification ```json { @@ -310,9 +365,9 @@ Codex output in `.codex/config.toml`: notify = ["python3", "scripts/on_turn_complete.py"] ``` -### 4) Cross-provider best_effort hook (all three providers) +### 5) Cross-provider best_effort hook (all three providers) -Covers `pre_tool_use` for Claude/Copilot and `turn_complete` for Codex in one file. Unsupported combinations are skipped rather than failing. +Covers `pre_tool_use` for Claude/Copilot/Codex and `turn_complete` for Codex in one file. Unsupported combinations are skipped rather than failing. ```json { @@ -338,7 +393,7 @@ Covers `pre_tool_use` for Claude/Copilot and `turn_complete` for Codex in one fi Behavior: - Claude: receives `PreToolUse`; `turn_complete` is skipped. - Copilot: receives `preToolUse`; `turn_complete` is skipped. -- Codex: receives `notify`; `pre_tool_use` is skipped. +- Codex: receives `PreToolUse` plus `notify`. --- @@ -354,6 +409,7 @@ Behavior: | `HOOK_COMMAND_MISSING` | `command` handler has no command field | | `HOOK_TIMEOUT_INVALID` | `timeoutSec`/`timeout` is not a positive number | | `HOOK_ENV_INVALID` | `env` is not a string-to-string map | +| `HOOK_STATUS_MESSAGE_INVALID` | `statusMessage` is present but not a non-empty string | | `HOOK_NOTIFY_EVENT_INVALID` | `notify` handler `event` is not `"agent-turn-complete"` | | `HOOK_NOTIFY_COMMAND_INVALID` | `notify` handler `command` is empty or wrong type | | `HOOK_EVENT_UNSUPPORTED` | Event is not supported by the target provider (strict mode) | @@ -395,5 +451,6 @@ If multiple hook entities for the same provider resolve to different target path - Claude Code hooks: https://code.claude.com/docs/en/hooks - GitHub Copilot hooks configuration: https://docs.github.com/en/copilot/reference/hooks-configuration - GitHub Copilot about hooks: https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks +- OpenAI Codex hooks: https://developers.openai.com/codex/hooks - OpenAI Codex config reference: https://developers.openai.com/codex/config-reference - Cursor hooks: https://docs.cursor.com/agent/hooks diff --git a/docs/architecture.md b/docs/architecture.md index 138bfc8..fd10789 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,7 +124,8 @@ Notes: - Claude: rendered into `.claude/settings.json` as `hooks` configuration. - Copilot: rendered into `.github/hooks/harness.generated.json` (`version: 1` + `hooks` map). -- Codex: projected into `.codex/config.toml` notify command only (canonical `turn_complete`). +- Codex: projected into `.codex/config.toml` as inline `[hooks]` lifecycle tables for supported events, plus top-level + `notify = [...]` for canonical `turn_complete`. In strict mode, unsupported provider/event/type projections fail with `HOOK_EVENT_UNSUPPORTED`. @@ -164,7 +165,7 @@ High-level flow (`loader.ts` + `planner.ts` + `engine.ts`): 5. Load canonical entities + provider override sidecars (with env var substitution). 6. Render provider artifacts through adapters: - per-entity renders (`prompt`, `skill`, `subagent`, `hook`) - - optional provider-state render (`codex` composite state for MCP/subagent/hook notify) + - optional provider-state render (`codex` composite state for MCP/subagent/lifecycle hooks/notify) 7. Detect collisions, unmanaged collisions, drift, creates, updates, and stale deletes. 8. Build deterministic `operations`, `nextLock`, and `nextManagedIndex`. 9. `plan` returns diagnostics/operations only. diff --git a/docs/hook-authoring.md b/docs/hook-authoring.md index fb926f2..39d8607 100644 --- a/docs/hook-authoring.md +++ b/docs/hook-authoring.md @@ -75,6 +75,7 @@ Supported fields: - `env` (string map) - `timeoutSec` - `timeout` +- `statusMessage` (Codex lifecycle hooks only) At least one command field is required. @@ -93,11 +94,29 @@ Supported fields: | --- | --- | --- | | Claude | most lifecycle events (mapped to Claude names) | `command` | | Copilot | `session_start`, `session_end`, `prompt_submit`, `pre_tool_use`, `post_tool_use`, `stop`, `subagent_stop`, `error` | `command` | -| Codex | `turn_complete` | `notify` and `command` (both normalized to `notify`) | +| Codex | `session_start`, `prompt_submit`, `pre_tool_use`, `permission_request`, `post_tool_use`, `stop`, plus legacy `turn_complete` notification | `command` for lifecycle hooks; `notify` and `command` for `turn_complete` | -## Codex command normalization +## Codex lifecycle hooks and notifications -When Codex projects a `turn_complete` handler, both `notify` and `command` handler types are converted into a TOML `notify` command array: +Codex lifecycle hooks render inline in `.codex/config.toml` under `[hooks]` and enable the canonical feature flag: + +```toml +[features] +hooks = true + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 scripts/check_bash.py" +timeout = 30 +statusMessage = "Checking Bash command" +``` + +Codex matcher support is limited to `session_start`, `pre_tool_use`, `permission_request`, and `post_tool_use`. `cwd` and `env` are not supported for Codex lifecycle command hooks. + +For legacy Codex notifications, `turn_complete` handlers still render to top-level `notify = [...]`. Both `notify` and `command` handler types are converted into a TOML notify command array: - `notify` handlers: the `command` field is used directly (arrays pass through; strings are wrapped as `["sh", "-lc", ""]`). - `command` handlers: the first available command field (`command`, `bash`, `linux`, `osx`, `powershell`, `windows`) is selected and wrapped the same way. @@ -222,7 +241,44 @@ Expected Copilot output fragment: } ``` -### 4) Codex turn-complete notification +### 4) Codex pre-tool guard + +Canonical source: + +```json +{ + "mode": "strict", + "events": { + "pre_tool_use": [ + { + "type": "command", + "matcher": "^Bash$", + "command": "python3 scripts/check_bash.py", + "timeout": 30, + "statusMessage": "Checking Bash command" + } + ] + } +} +``` + +Expected Codex output fragment in `.codex/config.toml`: + +```toml +[features] +hooks = true + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 scripts/check_bash.py" +timeout = 30 +statusMessage = "Checking Bash command" +``` + +### 5) Codex turn-complete notification Canonical source: @@ -246,7 +302,7 @@ Expected Codex output fragment in `.codex/config.toml`: notify = ["python3", "scripts/on_turn_complete.py"] ``` -### 5) One file for all providers (`best_effort`) +### 6) One file for all providers (`best_effort`) Canonical source: @@ -273,8 +329,8 @@ Canonical source: Behavior: -- Claude/Copilot use `pre_tool_use`. -- Codex uses `turn_complete`. +- Claude/Copilot/Codex use `pre_tool_use`. +- Codex also uses `turn_complete`. - Unsupported parts are skipped instead of failing. ## Target path overrides @@ -305,6 +361,7 @@ Rules: - `HOOK_COMMAND_MISSING` - `HOOK_TIMEOUT_INVALID` - `HOOK_ENV_INVALID` +- `HOOK_STATUS_MESSAGE_INVALID` - `HOOK_NOTIFY_EVENT_INVALID` - `HOOK_NOTIFY_COMMAND_INVALID` - `HOOK_EVENT_UNSUPPORTED` diff --git a/docs/providers.md b/docs/providers.md index d98e0ce..faf8634 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -49,8 +49,9 @@ Authoring examples: [Hook Authoring Guide](./hook-authoring.md) - Codex projects canonical `session_start`, `prompt_submit`, `pre_tool_use`, `permission_request`, `post_tool_use`, and `stop` into inline `[hooks]` tables in `.codex/config.toml`. - Codex also keeps canonical `turn_complete` as legacy `notify = [...]` output for current compatibility. -- When inline hooks are present, `harness` emits `[features] codex_hooks = true`. +- When inline hooks are present, `harness` emits `[features] hooks = true`; `codex_hooks` is treated as a deprecated upstream alias during import. - Matcher is supported for projected Codex `session_start`, `pre_tool_use`, `permission_request`, and `post_tool_use` events. +- `statusMessage` is supported for Codex lifecycle command hooks. - `cwd` and `env` command fields are unsupported for Codex inline hook projection and fail in strict mode. - Multiple distinct notify commands across enabled hooks fail with `HOOK_NOTIFY_CONFLICT`. @@ -230,6 +231,7 @@ For hook entities the default sidecar path is: ## References - [OpenAI Codex Config Reference](https://developers.openai.com/codex/config-reference) +- [OpenAI Codex Hooks](https://developers.openai.com/codex/hooks) - [OpenAI AGENTS.md Guide](https://developers.openai.com/codex/agents) - [Claude Code Settings](https://docs.claude.com/en/docs/claude-code/settings) - [Claude Code Hooks](https://code.claude.com/docs/en/hooks) diff --git a/docs/toolkit.hooks.md b/docs/toolkit.hooks.md index e2d03ec..aa210ba 100644 --- a/docs/toolkit.hooks.md +++ b/docs/toolkit.hooks.md @@ -31,6 +31,7 @@ Parses and validates canonical hook source documents loaded from `.harness/src/h - `command`, `windows`, `linux`, `osx`, `bash`, or `powershell` - `timeoutSec` / `timeout` must be positive numbers when present. - `env` must be an object of string values. +- `statusMessage`, when present, must be a non-empty string. ### `notify` handler validation @@ -41,4 +42,4 @@ Parses and validates canonical hook source documents loaded from `.harness/src/h ## Diagnostic families -Emits `HOOK_*` diagnostics for invalid mode/event/handler/timeout/env/notify shape. +Emits `HOOK_*` diagnostics for invalid mode/event/handler/timeout/env/status-message/notify shape. diff --git a/docs/toolkit.provider.codex.md b/docs/toolkit.provider.codex.md index 3a0dcab..b3e35fa 100644 --- a/docs/toolkit.provider.codex.md +++ b/docs/toolkit.provider.codex.md @@ -18,7 +18,7 @@ Builds the Codex provider adapter. - `mcp_servers` (merged MCP servers) - `agents.` entries (enabled subagents, with `developer_instructions`, `description`, and supported provider-specific options) - - inline `[hooks]` config plus `[features] codex_hooks = true` when Codex hook events are projected + - inline `[hooks]` config plus `[features] hooks = true` when Codex lifecycle hook events are projected - `notify = [...]` (projected from hook `turn_complete`) - Returns no artifact when merged payload is empty. - Output string is normalized to one trailing newline. diff --git a/docs/toolkit.provider.hook-capabilities.md b/docs/toolkit.provider.hook-capabilities.md new file mode 100644 index 0000000..7899660 --- /dev/null +++ b/docs/toolkit.provider.hook-capabilities.md @@ -0,0 +1,20 @@ +# `packages/toolkit/src/provider-adapters/hook-capabilities.ts` + +## Purpose + +Centralizes provider hook event capabilities so renderers and legacy import paths use the same support matrix. + +## Core exports + +- `HOOK_PROVIDER_CAPABILITIES` +- `getHookEventCapability(provider, eventName)` +- `nativeToCanonicalHookEvent(provider, nativeEvent)` + +## Captured capabilities + +- Native provider event name for each supported canonical event. +- Whether `matcher` is supported for that provider/event pair. +- Command-handler field support such as timeout key, `cwd`, `env`, and `statusMessage`. + +This module is data-oriented. Provider renderers still own provider-native output shapes, so Claude/Codex grouped hook +config and Copilot/Cursor flat hook config stay separate. diff --git a/docs/toolkit.provider.hooks.md b/docs/toolkit.provider.hooks.md index 5dcbf0b..ea093f8 100644 --- a/docs/toolkit.provider.hooks.md +++ b/docs/toolkit.provider.hooks.md @@ -10,8 +10,12 @@ Shared hook projection utilities for provider adapters. - `renderClaudeHookSettings(hooks)` - `renderCopilotHookConfig(hooks)` - `renderCursorHookConfig(hooks)` +- `renderCodexHookConfigObject(hooks)` - `resolveCodexNotifyCommand(hooks)` +Provider event names and matcher support are shared through `hook-capabilities.ts` so forward rendering and `u-haul` +reverse imports use the same mapping. + ## Provider projections ### Claude @@ -42,8 +46,12 @@ Shared hook projection utilities for provider adapters. ### Codex -- Projects canonical `turn_complete` events in all modes; strict mode controls whether unsupported events throw vs. are skipped. -- Normalizes either canonical `notify` or `command` handlers into notify command arrays. +- Maps canonical `session_start`, `prompt_submit`, `pre_tool_use`, `permission_request`, `post_tool_use`, and `stop` + into Codex inline `[hooks]` tables. +- Emits `[features] hooks = true` when lifecycle hooks are projected. +- Supports `statusMessage` on Codex lifecycle command handlers. +- Projects canonical `turn_complete` as the legacy top-level `notify = [...]` command. +- Normalizes either canonical `notify` or `command` `turn_complete` handlers into notify command arrays. - Returns one merged notify command, failing on incompatible multiple values (`HOOK_NOTIFY_CONFLICT`). ## Strict vs best-effort diff --git a/docs/toolkit.types.md b/docs/toolkit.types.md index ec1a600..5c019dc 100644 --- a/docs/toolkit.types.md +++ b/docs/toolkit.types.md @@ -32,6 +32,6 @@ Defines shared toolkit interfaces for canonical entities, provider adapters, dia - `ProviderAdapter.renderMcp` accepts all canonical MCP configs and optional per-entity override map. - `ProviderAdapter.renderSubagent` renders provider-native subagent artifacts for providers with per-subagent files. - `ProviderAdapter.renderHooks` renders provider-native hook artifacts for providers with dedicated hook outputs. -- `ProviderAdapter.renderProviderState` supports composite provider artifacts (Codex merges MCP, subagents, and hook notify state). +- `ProviderAdapter.renderProviderState` supports composite provider artifacts (Codex merges MCP, subagents, lifecycle hooks, and notify state). - `ProviderStateInput` includes MCP/subagent/hook arrays and per-entity override maps. - CLI entity types now include `hook`. diff --git a/docs/toolkit.u-haul.md b/docs/toolkit.u-haul.md index ac7c5e7..e2cd699 100644 --- a/docs/toolkit.u-haul.md +++ b/docs/toolkit.u-haul.md @@ -31,7 +31,7 @@ Implements `init --u-haul`, a legacy import flow that migrates provider-owned fi - Skills: `.codex/skills/*`, `.claude/skills/*`, `.github/skills/*` - MCP: `.codex/config.toml` (`mcp_servers`), `.mcp.json` (`mcpServers`/`servers`), `.vscode/mcp.json` (`servers`) - Subagents: `.codex/config.toml` (`agents`), `.claude/agents/*.md`, `.github/agents/*.agent.md` -- Hooks: `.codex/config.toml` (`notify`), `.claude/settings.json` (`hooks`), `.github/hooks/harness.generated.json` +- Hooks: `.codex/config.toml` (`hooks` and `notify`), `.codex/hooks.json`, `.claude/settings.json` (`hooks`), `.github/hooks/harness.generated.json` - Settings: provider settings payloads after removing sections consumed by imported entities - Commands: `.claude/commands/*.md`, `.github/prompts/*.prompt.md` diff --git a/packages/toolkit/src/hooks.ts b/packages/toolkit/src/hooks.ts index 5fd18e5..586e621 100644 --- a/packages/toolkit/src/hooks.ts +++ b/packages/toolkit/src/hooks.ts @@ -232,6 +232,7 @@ function parseCommandHandler( const timeoutSecIsPlaceholder = isEnvPlaceholderToken(input.timeoutSec); const timeoutIsPlaceholder = isEnvPlaceholderToken(input.timeout); const env = asOptionalStringMap(input.env); + const statusMessage = asOptionalString(input.statusMessage); if (!command && !windows && !linux && !osx && !bash && !powershell) { diagnostics.push({ @@ -279,6 +280,17 @@ function parseCommandHandler( return undefined; } + if (typeof input.statusMessage !== "undefined" && !statusMessage) { + diagnostics.push({ + code: "HOOK_STATUS_MESSAGE_INVALID", + severity: "error", + message: `Hook '${entityId}' event '${eventName}' handler #${index + 1} has invalid statusMessage`, + path: sourcePath, + entityId, + }); + return undefined; + } + return { type: "command", matcher, @@ -292,6 +304,7 @@ function parseCommandHandler( env, timeoutSec, timeout, + statusMessage, }; } diff --git a/packages/toolkit/src/provider-adapters/hook-capabilities.ts b/packages/toolkit/src/provider-adapters/hook-capabilities.ts new file mode 100644 index 0000000..4a8565e --- /dev/null +++ b/packages/toolkit/src/provider-adapters/hook-capabilities.ts @@ -0,0 +1,141 @@ +import type { CanonicalHookEvent, ProviderId } from "../types.js"; + +export interface HookEventCapability { + nativeEvent: string; + matcher: boolean; +} + +export interface HookCommandCapability { + timeoutKey: "timeout" | "timeoutSec"; + supportsCwd: boolean; + supportsEnv: boolean; + supportsStatusMessage: boolean; +} + +export interface HookProviderCapabilities { + events: Partial>; + command: HookCommandCapability; +} + +function event(nativeEvent: string, matcher = false): HookEventCapability { + return { nativeEvent, matcher }; +} + +export const HOOK_PROVIDER_CAPABILITIES = { + claude: { + events: { + session_start: event("SessionStart", true), + session_end: event("SessionEnd", true), + prompt_submit: event("UserPromptSubmit"), + pre_tool_use: event("PreToolUse", true), + permission_request: event("PermissionRequest", true), + post_tool_use: event("PostToolUse", true), + post_tool_failure: event("PostToolUseFailure", true), + notification: event("Notification", true), + subagent_start: event("SubagentStart", true), + subagent_stop: event("SubagentStop", true), + stop: event("Stop"), + stop_failure: event("StopFailure", true), + teammate_idle: event("TeammateIdle"), + task_completed: event("TaskCompleted"), + instructions_loaded: event("InstructionsLoaded", true), + config_change: event("ConfigChange", true), + worktree_create: event("WorktreeCreate"), + worktree_remove: event("WorktreeRemove"), + pre_compact: event("PreCompact", true), + post_compact: event("PostCompact", true), + elicitation: event("Elicitation", true), + elicitation_result: event("ElicitationResult", true), + setup: event("Setup"), + user_prompt_expansion: event("UserPromptExpansion"), + permission_denied: event("PermissionDenied"), + post_tool_batch: event("PostToolBatch", true), + cwd_changed: event("CwdChanged"), + file_changed: event("FileChanged", true), + task_created: event("TaskCreated"), + }, + command: { + timeoutKey: "timeout", + supportsCwd: true, + supportsEnv: true, + supportsStatusMessage: false, + }, + }, + codex: { + events: { + session_start: event("SessionStart", true), + prompt_submit: event("UserPromptSubmit"), + pre_tool_use: event("PreToolUse", true), + permission_request: event("PermissionRequest", true), + post_tool_use: event("PostToolUse", true), + stop: event("Stop"), + }, + command: { + timeoutKey: "timeout", + supportsCwd: false, + supportsEnv: false, + supportsStatusMessage: true, + }, + }, + copilot: { + events: { + session_start: event("sessionStart"), + session_end: event("sessionEnd"), + prompt_submit: event("userPromptSubmitted"), + pre_tool_use: event("preToolUse"), + post_tool_use: event("postToolUse"), + pre_compact: event("preCompact"), + stop: event("agentStop"), + subagent_start: event("subagentStart"), + subagent_stop: event("subagentStop"), + error: event("errorOccurred"), + }, + command: { + timeoutKey: "timeoutSec", + supportsCwd: true, + supportsEnv: true, + supportsStatusMessage: false, + }, + }, + cursor: { + events: { + session_start: event("sessionStart", true), + session_end: event("sessionEnd", true), + prompt_submit: event("beforeSubmitPrompt", true), + pre_tool_use: event("preToolUse", true), + post_tool_use: event("postToolUse", true), + post_tool_failure: event("postToolUseFailure", true), + subagent_start: event("subagentStart", true), + subagent_stop: event("subagentStop", true), + pre_compact: event("preCompact", true), + stop: event("stop", true), + }, + command: { + timeoutKey: "timeout", + supportsCwd: false, + supportsEnv: false, + supportsStatusMessage: false, + }, + }, +} as const satisfies Record; + +export function getHookEventCapability( + provider: ProviderId, + eventName: CanonicalHookEvent, +): HookEventCapability | undefined { + const events: Partial> = HOOK_PROVIDER_CAPABILITIES[provider].events; + return events[eventName]; +} + +export function nativeToCanonicalHookEvent(provider: ProviderId, nativeEvent: string): CanonicalHookEvent | undefined { + const events: Partial> = HOOK_PROVIDER_CAPABILITIES[provider].events; + for (const [canonicalEvent, capability] of Object.entries(events) as Array< + [CanonicalHookEvent, HookEventCapability] + >) { + if (capability.nativeEvent === nativeEvent) { + return canonicalEvent; + } + } + + return undefined; +} diff --git a/packages/toolkit/src/provider-adapters/hooks.ts b/packages/toolkit/src/provider-adapters/hooks.ts index ab95d4a..8dcf06b 100644 --- a/packages/toolkit/src/provider-adapters/hooks.ts +++ b/packages/toolkit/src/provider-adapters/hooks.ts @@ -8,104 +8,7 @@ import type { ProviderOverride, } from "../types.js"; import { normalizeRelativePath, stableStringify } from "../utils.js"; - -const CLAUDE_EVENT_MAP: Partial> = { - session_start: "SessionStart", - session_end: "SessionEnd", - prompt_submit: "UserPromptSubmit", - pre_tool_use: "PreToolUse", - permission_request: "PermissionRequest", - post_tool_use: "PostToolUse", - post_tool_failure: "PostToolUseFailure", - notification: "Notification", - subagent_start: "SubagentStart", - subagent_stop: "SubagentStop", - stop: "Stop", - stop_failure: "StopFailure", - teammate_idle: "TeammateIdle", - task_completed: "TaskCompleted", - instructions_loaded: "InstructionsLoaded", - config_change: "ConfigChange", - worktree_create: "WorktreeCreate", - worktree_remove: "WorktreeRemove", - pre_compact: "PreCompact", - post_compact: "PostCompact", - elicitation: "Elicitation", - elicitation_result: "ElicitationResult", - setup: "Setup", - user_prompt_expansion: "UserPromptExpansion", - permission_denied: "PermissionDenied", - post_tool_batch: "PostToolBatch", - cwd_changed: "CwdChanged", - file_changed: "FileChanged", - task_created: "TaskCreated", -}; - -const COPILOT_EVENT_MAP: Partial> = { - session_start: "sessionStart", - session_end: "sessionEnd", - prompt_submit: "userPromptSubmitted", - pre_tool_use: "preToolUse", - post_tool_use: "postToolUse", - pre_compact: "preCompact", - stop: "agentStop", - subagent_start: "subagentStart", - subagent_stop: "subagentStop", - error: "errorOccurred", -}; - -// Cursor also supports beforeShellExecution, afterShellExecution, beforeMCPExecution, -// afterMCPExecution, beforeReadFile, afterFileEdit, afterAgentResponse, afterAgentThought, -// beforeTabFileRead, and afterTabFileEdit — these have no canonical equivalents yet. -const CURSOR_EVENT_MAP: Partial> = { - session_start: "sessionStart", - session_end: "sessionEnd", - prompt_submit: "beforeSubmitPrompt", - pre_tool_use: "preToolUse", - post_tool_use: "postToolUse", - post_tool_failure: "postToolUseFailure", - subagent_start: "subagentStart", - subagent_stop: "subagentStop", - pre_compact: "preCompact", - stop: "stop", -}; - -const CODEX_EVENT_MAP: Partial> = { - session_start: "SessionStart", - prompt_submit: "UserPromptSubmit", - pre_tool_use: "PreToolUse", - permission_request: "PermissionRequest", - post_tool_use: "PostToolUse", - stop: "Stop", -}; - -const CLAUDE_MATCHER_SUPPORTED_EVENTS = new Set([ - "pre_tool_use", - "post_tool_use", - "post_tool_failure", - "permission_request", - "session_start", - "session_end", - "notification", - "subagent_start", - "subagent_stop", - "config_change", - "stop_failure", - "instructions_loaded", - "pre_compact", - "post_compact", - "elicitation", - "elicitation_result", - "post_tool_batch", - "file_changed", -]); - -const CODEX_MATCHER_SUPPORTED_EVENTS = new Set([ - "session_start", - "pre_tool_use", - "permission_request", - "post_tool_use", -]); +import { getHookEventCapability } from "./hook-capabilities.js"; export function resolveHookTargetPath( provider: ProviderId, @@ -136,65 +39,7 @@ export function resolveHookTargetPath( } export function renderClaudeHookSettings(hooks: ReadonlyArray): string { - const events: Record> }>> = {}; - - for (const hook of hooks) { - for (const [eventName, handlers] of Object.entries(hook.events) as Array< - [CanonicalHookEvent, CanonicalHook["events"][CanonicalHookEvent]] - >) { - const mappedEvent = CLAUDE_EVENT_MAP[eventName]; - if (!mappedEvent) { - handleUnsupported(hook, "claude", `event '${eventName}'`); - continue; - } - - if (!handlers || handlers.length === 0) { - continue; - } - - const groups = new Map>>(); - const matcherSupported = CLAUDE_MATCHER_SUPPORTED_EVENTS.has(eventName); - - for (const handler of handlers) { - if (handler.type !== "command") { - handleUnsupported(hook, "claude", `handler type '${handler.type}'`); - continue; - } - - if (handler.matcher && !matcherSupported) { - handleUnsupported(hook, "claude", `matcher on event '${eventName}'`); - continue; - } - - const rendered = renderClaudeCommand(handler); - if (!rendered) { - handleUnsupported(hook, "claude", "command fields"); - continue; - } - - const groupKey = matcherSupported ? handler.matcher?.trim() || "__all__" : "__all__"; - const entry = groups.get(groupKey) ?? []; - entry.push(rendered); - groups.set(groupKey, entry); - } - - if (groups.size === 0) { - continue; - } - - const groupEntries = [...groups.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([matcher, groupedHooks]) => - matcher === "__all__" - ? ({ hooks: groupedHooks } as { matcher?: string; hooks: Array> }) - : ({ matcher, hooks: groupedHooks } as { matcher?: string; hooks: Array> }), - ); - - events[mappedEvent] = [...(events[mappedEvent] ?? []), ...groupEntries]; - } - } - - return stableStringify({ hooks: events }); + return stableStringify({ hooks: renderGroupedHookEvents(hooks, "claude", renderClaudeCommand) }); } export function renderCopilotHookConfig(hooks: ReadonlyArray): string { @@ -204,8 +49,8 @@ export function renderCopilotHookConfig(hooks: ReadonlyArray): st for (const [eventName, handlers] of Object.entries(hook.events) as Array< [CanonicalHookEvent, CanonicalHook["events"][CanonicalHookEvent]] >) { - const mappedEvent = COPILOT_EVENT_MAP[eventName]; - if (!mappedEvent) { + const eventCapability = getHookEventCapability("copilot", eventName); + if (!eventCapability) { handleUnsupported(hook, "copilot", `event '${eventName}'`); continue; } @@ -225,13 +70,13 @@ export function renderCopilotHookConfig(hooks: ReadonlyArray): st continue; } - const rendered = renderCopilotCommand(handler); + const rendered = renderCopilotCommand(hook, handler); if (!rendered) { handleUnsupported(hook, "copilot", "command fields"); continue; } - events[mappedEvent] = [...(events[mappedEvent] ?? []), rendered]; + events[eventCapability.nativeEvent] = [...(events[eventCapability.nativeEvent] ?? []), rendered]; } } } @@ -249,8 +94,8 @@ export function renderCursorHookConfig(hooks: ReadonlyArray): str for (const [eventName, handlers] of Object.entries(hook.events) as Array< [CanonicalHookEvent, CanonicalHook["events"][CanonicalHookEvent]] >) { - const mappedEvent = CURSOR_EVENT_MAP[eventName]; - if (!mappedEvent) { + const eventCapability = getHookEventCapability("cursor", eventName); + if (!eventCapability) { handleUnsupported(hook, "cursor", `event '${eventName}'`); continue; } @@ -270,7 +115,7 @@ export function renderCursorHookConfig(hooks: ReadonlyArray): str continue; } - events[mappedEvent] = [...(events[mappedEvent] ?? []), rendered]; + events[eventCapability.nativeEvent] = [...(events[eventCapability.nativeEvent] ?? []), rendered]; } } } @@ -281,20 +126,25 @@ export function renderCursorHookConfig(hooks: ReadonlyArray): str }); } -export function renderCodexHookConfigObject(hooks: ReadonlyArray): Record | undefined { +function renderGroupedHookEvents( + hooks: ReadonlyArray, + provider: "claude" | "codex", + renderCommand: (hook: CanonicalHook, handler: CanonicalHookCommandHandler) => Record | undefined, + ignoredEvents = new Set(), +): Record> }>> { const events: Record> }>> = {}; for (const hook of hooks) { for (const [eventName, handlers] of Object.entries(hook.events) as Array< [CanonicalHookEvent, CanonicalHook["events"][CanonicalHookEvent]] >) { - if (eventName === "turn_complete") { + if (ignoredEvents.has(eventName)) { continue; } - const mappedEvent = CODEX_EVENT_MAP[eventName]; - if (!mappedEvent) { - handleUnsupported(hook, "codex", `event '${eventName}'`); + const eventCapability = getHookEventCapability(provider, eventName); + if (!eventCapability) { + handleUnsupported(hook, provider, `event '${eventName}'`); continue; } @@ -303,25 +153,24 @@ export function renderCodexHookConfigObject(hooks: ReadonlyArray) } const groups = new Map>>(); - const matcherSupported = CODEX_MATCHER_SUPPORTED_EVENTS.has(eventName); for (const handler of handlers) { if (handler.type !== "command") { - handleUnsupported(hook, "codex", `handler type '${handler.type}'`); + handleUnsupported(hook, provider, `handler type '${handler.type}'`); continue; } - if (handler.matcher && !matcherSupported) { - handleUnsupported(hook, "codex", `matcher on event '${eventName}'`); + if (handler.matcher && !eventCapability.matcher) { + handleUnsupported(hook, provider, `matcher on event '${eventName}'`); continue; } - const rendered = renderCodexHookCommand(hook, handler); + const rendered = renderCommand(hook, handler); if (!rendered) { continue; } - const groupKey = matcherSupported ? handler.matcher?.trim() || "__all__" : "__all__"; + const groupKey = eventCapability.matcher ? handler.matcher?.trim() || "__all__" : "__all__"; const entry = groups.get(groupKey) ?? []; entry.push(rendered); groups.set(groupKey, entry); @@ -339,17 +188,23 @@ export function renderCodexHookConfigObject(hooks: ReadonlyArray) : ({ matcher, hooks: groupedHooks } as { matcher?: string; hooks: Array> }), ); - events[mappedEvent] = [...(events[mappedEvent] ?? []), ...groupEntries]; + events[eventCapability.nativeEvent] = [...(events[eventCapability.nativeEvent] ?? []), ...groupEntries]; } } + return events; +} + +export function renderCodexHookConfigObject(hooks: ReadonlyArray): Record | undefined { + const events = renderGroupedHookEvents(hooks, "codex", renderCodexHookCommand, new Set(["turn_complete"])); + if (Object.keys(events).length === 0) { return undefined; } return { features: { - codex_hooks: true, + hooks: true, }, hooks: events, }; @@ -439,11 +294,22 @@ function renderCodexHookCommand( if (timeout) { output.timeout = timeout; } + if (handler.statusMessage) { + output.statusMessage = handler.statusMessage; + } return output; } -function renderClaudeCommand(handler: CanonicalHookCommandHandler): Record | undefined { +function renderClaudeCommand( + hook: CanonicalHook, + handler: CanonicalHookCommandHandler, +): Record | undefined { + if (handler.statusMessage) { + handleUnsupported(hook, "claude", "'statusMessage' on command handler"); + return undefined; + } + const command = resolveGenericCommand(handler); if (!command) { return undefined; @@ -469,7 +335,15 @@ function renderClaudeCommand(handler: CanonicalHookCommandHandler): Record | undefined { +function renderCopilotCommand( + hook: CanonicalHook, + handler: CanonicalHookCommandHandler, +): Record | undefined { + if (handler.statusMessage) { + handleUnsupported(hook, "copilot", "'statusMessage' on command handler"); + return undefined; + } + const bash = handler.bash ?? handler.linux ?? handler.osx ?? handler.command; const powershell = handler.powershell ?? handler.windows ?? handler.command; @@ -508,6 +382,10 @@ function renderCursorCommand( hook: CanonicalHook, handler: CanonicalHookCommandHandler, ): Record | undefined { + if (handler.statusMessage) { + handleUnsupported(hook, "cursor", "'statusMessage' on command handler"); + return undefined; + } if (handler.cwd) { handleUnsupported(hook, "cursor", "'cwd' on command handler"); return undefined; diff --git a/packages/toolkit/src/types.ts b/packages/toolkit/src/types.ts index e75e8f3..728164d 100644 --- a/packages/toolkit/src/types.ts +++ b/packages/toolkit/src/types.ts @@ -88,6 +88,7 @@ export interface CanonicalHookCommandHandler { env?: Record; timeoutSec?: number; timeout?: number; + statusMessage?: string; } export interface CanonicalHookNotifyHandler { diff --git a/packages/toolkit/src/u-haul.ts b/packages/toolkit/src/u-haul.ts index d14107c..7e5b99b 100644 --- a/packages/toolkit/src/u-haul.ts +++ b/packages/toolkit/src/u-haul.ts @@ -19,6 +19,7 @@ import { import { validateEntityId } from "./engine/utils.js"; import { HarnessEngine } from "./engine.js"; import { parseCanonicalHookDocument } from "./hooks.js"; +import { getHookEventCapability, nativeToCanonicalHookEvent } from "./provider-adapters/hook-capabilities.js"; import { renderSubagentMarkdown } from "./provider-adapters/subagents.js"; import { listFilesRecursively } from "./repository.js"; import type { ApplyResult, CanonicalHookEvent, CanonicalSubagent, CliEntityType, ProviderId } from "./types.js"; @@ -55,42 +56,6 @@ const U_HAUL_TYPE_ORDER: readonly CliEntityType[] = [ "command", ]; -const CLAUDE_EVENT_FROM_PROVIDER: Record = { - SessionStart: "session_start", - SessionEnd: "session_end", - UserPromptSubmit: "prompt_submit", - PreToolUse: "pre_tool_use", - PermissionRequest: "permission_request", - PostToolUse: "post_tool_use", - PostToolUseFailure: "post_tool_failure", - Notification: "notification", - SubagentStart: "subagent_start", - SubagentStop: "subagent_stop", - Stop: "stop", - StopFailure: "stop_failure", - TeammateIdle: "teammate_idle", - TaskCompleted: "task_completed", - InstructionsLoaded: "instructions_loaded", - ConfigChange: "config_change", - WorktreeCreate: "worktree_create", - WorktreeRemove: "worktree_remove", - PreCompact: "pre_compact", - PostCompact: "post_compact", - Elicitation: "elicitation", - ElicitationResult: "elicitation_result", -}; - -const COPILOT_EVENT_FROM_PROVIDER: Record = { - sessionStart: "session_start", - sessionEnd: "session_end", - userPromptSubmitted: "prompt_submit", - preToolUse: "pre_tool_use", - postToolUse: "post_tool_use", - agentStop: "stop", - subagentStop: "subagent_stop", - errorOccurred: "error", -}; - export interface LegacyAssetsDetection { hasLegacyAssets: boolean; providers: ProviderId[]; @@ -278,6 +243,7 @@ export async function detectLegacyAssets(cwd: string): Promise relative.endsWith("/SKILL.md")), }, { provider: "codex", legacyPath: ".codex/config.toml", check: pathExists(cwd, ".codex/config.toml") }, + { provider: "codex", legacyPath: ".codex/hooks.json", check: pathExists(cwd, ".codex/hooks.json") }, { provider: "claude", legacyPath: ".mcp.json", check: pathExists(cwd, ".mcp.json") }, { provider: "copilot", legacyPath: ".vscode/mcp.json", check: pathExists(cwd, ".vscode/mcp.json") }, { @@ -411,6 +377,7 @@ async function buildUHaulPlan(cwd: string, precedence: readonly ProviderId[]): P await parsePromptSources(collection); await parseSkillSources(collection); await parseCodexConfig(collection); + await parseCodexHooksJson(collection); await parseStandaloneMcpFiles(collection); await parseSubagentFiles(collection); await parseClaudeSettings(collection); @@ -616,6 +583,7 @@ async function parseCodexConfig(collection: CandidateCollection): Promise const candidateCountBefore = collection.candidates.length; const consumedKeys = new Set(); + let importedHooks = false; if (Object.hasOwn(payload, "mcp_servers")) { consumedKeys.add("mcp_servers"); @@ -632,9 +600,18 @@ async function parseCodexConfig(collection: CandidateCollection): Promise parseCodexNotify(collection, `${relativePath}#notify`, payload.notify); } - const settingsPayload = Object.fromEntries( - Object.entries(payload).filter(([key]) => !consumedKeys.has(key)), - ) as Record; + if (Object.hasOwn(payload, "hooks")) { + consumedKeys.add("hooks"); + importedHooks = parseGroupedProviderHooks(collection, "codex", `${relativePath}#hooks`, payload.hooks); + } + + let settingsPayload = Object.fromEntries(Object.entries(payload).filter(([key]) => !consumedKeys.has(key))) as Record< + string, + unknown + >; + if (importedHooks) { + settingsPayload = removeDeprecatedCodexHookFeatureAlias(settingsPayload); + } if (Object.keys(settingsPayload).length > 0) { pushCandidate(collection, { @@ -653,6 +630,46 @@ async function parseCodexConfig(collection: CandidateCollection): Promise } } +async function parseCodexHooksJson(collection: CandidateCollection): Promise { + const relativePath = ".codex/hooks.json"; + const absolutePath = path.join(collection.cwd, relativePath); + const text = await readTextIfExists(absolutePath); + if (text === null) { + return; + } + + let payload: Record; + try { + payload = parseJsonAsRecord(text); + } catch (error) { + pushParseError(collection, relativePath, `invalid JSON: ${toErrorMessage(error)}`); + return; + } + + const candidateCountBefore = collection.candidates.length; + parseGroupedProviderHooks(collection, "codex", `${relativePath}#hooks`, payload.hooks); + + if (collection.candidates.length > candidateCountBefore) { + collection.deletionPaths.add(relativePath); + } +} + +function removeDeprecatedCodexHookFeatureAlias(settingsPayload: Record): Record { + const features = settingsPayload.features; + if (!isRecord(features) || !Object.hasOwn(features, "codex_hooks")) { + return settingsPayload; + } + + const { codex_hooks: _codexHooks, ...remainingFeatures } = features; + const output = { ...settingsPayload }; + if (Object.keys(remainingFeatures).length > 0) { + output.features = remainingFeatures; + } else { + delete output.features; + } + return output; +} + function parseCodexAgents(collection: CandidateCollection, sourcePath: string, value: unknown): void { if (!isRecord(value)) { pushParseError(collection, sourcePath, "expected TOML table for 'agents'"); @@ -902,84 +919,7 @@ async function parseClaudeSettings(collection: CandidateCollection): Promise> = []; - - for (let index = 0; index < groupsValue.length; index += 1) { - const group = groupsValue[index]; - if (!isRecord(group)) { - pushParseError( - collection, - `${relativePath}#hooks.${providerEvent}[${index}]`, - "group entry must be an object", - ); - continue; - } - - const matcher = asNonEmptyString(group.matcher); - const hookEntries = group.hooks; - if (!Array.isArray(hookEntries)) { - pushParseError( - collection, - `${relativePath}#hooks.${providerEvent}[${index}]`, - "group.hooks must be an array", - ); - continue; - } - - for (let hookIndex = 0; hookIndex < hookEntries.length; hookIndex += 1) { - const hook = hookEntries[hookIndex]; - if (!isRecord(hook)) { - pushParseError( - collection, - `${relativePath}#hooks.${providerEvent}[${index}].hooks[${hookIndex}]`, - "hook entry must be an object", - ); - continue; - } - - const parsed = parseCommandHookHandler(collection, hook, { - sourcePath: `${relativePath}#hooks.${providerEvent}[${index}].hooks[${hookIndex}]`, - allowMatcher: false, - }); - if (!parsed) { - continue; - } - - if (matcher) { - parsed.matcher = matcher; - } - - handlers.push(parsed); - } - } - - if (handlers.length > 0) { - addHookCandidate(collection, { - provider: "claude", - id: canonicalEvent, - event: canonicalEvent, - handlers, - sourcePath: `${relativePath}#hooks.${providerEvent}`, - }); - } - } - } + parseGroupedProviderHooks(collection, "claude", `${relativePath}#hooks`, payload.hooks); } const settingsPayload = Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "hooks")) as Record< @@ -1004,6 +944,96 @@ async function parseClaudeSettings(collection: CandidateCollection): Promise> = []; + + for (let index = 0; index < groupsValue.length; index += 1) { + const group = groupsValue[index]; + if (!isRecord(group)) { + pushParseError(collection, `${sourcePath}.${providerEvent}[${index}]`, "group entry must be an object"); + continue; + } + + const matcher = asNonEmptyString(group.matcher); + const hookEntries = group.hooks; + if (!Array.isArray(hookEntries)) { + pushParseError(collection, `${sourcePath}.${providerEvent}[${index}]`, "group.hooks must be an array"); + continue; + } + + for (let hookIndex = 0; hookIndex < hookEntries.length; hookIndex += 1) { + const hook = hookEntries[hookIndex]; + if (!isRecord(hook)) { + pushParseError( + collection, + `${sourcePath}.${providerEvent}[${index}].hooks[${hookIndex}]`, + "hook entry must be an object", + ); + continue; + } + + if (provider === "codex" && isCodexSkippedHookHandler(hook)) { + continue; + } + + const parsed = parseCommandHookHandler(collection, hook, { + sourcePath: `${sourcePath}.${providerEvent}[${index}].hooks[${hookIndex}]`, + allowMatcher: false, + allowStatusMessage: provider === "codex", + }); + if (!parsed) { + continue; + } + + if (matcher && eventCapability?.matcher) { + parsed.matcher = matcher; + } + + handlers.push(parsed); + } + } + + if (handlers.length > 0) { + importedAny = true; + addHookCandidate(collection, { + provider, + id: canonicalEvent, + event: canonicalEvent, + handlers, + sourcePath: `${sourcePath}.${providerEvent}`, + }); + } + } + + return importedAny; +} + async function parseCopilotHooks(collection: CandidateCollection): Promise { const relativePath = ".github/hooks/harness.generated.json"; const absolutePath = path.join(collection.cwd, relativePath); @@ -1029,7 +1059,7 @@ async function parseCopilotHooks(collection: CandidateCollection): Promise const candidateCountBefore = collection.candidates.length; for (const [providerEvent, entriesValue] of sortedEntries(hooks)) { - const canonicalEvent = COPILOT_EVENT_FROM_PROVIDER[providerEvent]; + const canonicalEvent = nativeToCanonicalHookEvent("copilot", providerEvent); if (!canonicalEvent) { pushParseError(collection, `${relativePath}#hooks.${providerEvent}`, "unsupported Copilot hook event"); continue; @@ -1079,7 +1109,7 @@ async function parseCopilotHooks(collection: CandidateCollection): Promise function parseCommandHookHandler( collection: CandidateCollection, value: Record, - input: { sourcePath: string; allowMatcher: boolean }, + input: { sourcePath: string; allowMatcher: boolean; allowStatusMessage?: boolean }, ): Record | undefined { const type = value.type; if (typeof type !== "undefined" && type !== "command") { @@ -1121,6 +1151,15 @@ function parseCommandHookHandler( if (bash) output.bash = bash; if (powershell) output.powershell = powershell; + const statusMessage = asNonEmptyString(value.statusMessage); + if (Object.hasOwn(value, "statusMessage") && input.allowStatusMessage) { + if (!statusMessage) { + pushParseError(collection, input.sourcePath, "statusMessage must be a non-empty string"); + return undefined; + } + output.statusMessage = statusMessage; + } + const cwd = asNonEmptyString(value.cwd); if (cwd) { output.cwd = cwd; @@ -1155,6 +1194,10 @@ function parseCommandHookHandler( return output; } +function isCodexSkippedHookHandler(value: Record): boolean { + return value.async === true || value.type === "prompt" || value.type === "agent"; +} + function addHookCandidate( collection: CandidateCollection, input: { diff --git a/packages/toolkit/test/e2e/user-journeys/local-lifecycle.e2e.test.ts b/packages/toolkit/test/e2e/user-journeys/local-lifecycle.e2e.test.ts index 82f5a9e..ab6ffb6 100644 --- a/packages/toolkit/test/e2e/user-journeys/local-lifecycle.e2e.test.ts +++ b/packages/toolkit/test/e2e/user-journeys/local-lifecycle.e2e.test.ts @@ -239,7 +239,7 @@ describe("local lifecycle journey", { timeout: 120_000 }, () => { "utf8", ); - // Hook: lint-guard (cross-provider: pre_tool_use works on claude+copilot, skipped on codex) + // Hook: lint-guard (cross-provider: pre_tool_use works on claude+copilot+codex) await fs.writeFile( path.join(workspace, ".harness/src/hooks/lint-guard.json"), JSON.stringify( @@ -318,11 +318,14 @@ describe("local lifecycle journey", { timeout: 120_000 }, () => { ); assert.ok(await fileExists(path.join(workspace, ".codex/config.toml")), "codex config.toml"); - // codex config.toml should have MCP servers, subagent, and notify + // codex config.toml should have MCP servers, subagent, lifecycle hooks, and notify const codexToml = await readWorkspaceText(workspace, ".codex/config.toml"); assert.match(codexToml, /\[mcp_servers\.playwright\]/u, "codex has playwright MCP"); assert.match(codexToml, /\[mcp_servers\.sentry\]/u, "codex has sentry MCP"); assert.match(codexToml, /\[agents\.researcher\]/u, "codex has researcher agent"); + assert.match(codexToml, /hooks = true/u, "codex lifecycle hooks enabled"); + assert.doesNotMatch(codexToml, /codex_hooks/u, "codex avoids deprecated hook feature alias"); + assert.match(codexToml, /\[\[hooks\.PreToolUse\]\]/u, "codex has PreToolUse lifecycle hook"); assert.match(codexToml, /notify/u, "codex has notify from hook"); // --- Claude outputs --- diff --git a/packages/toolkit/test/e2e/user-journeys/u-haul-workflow.e2e.test.ts b/packages/toolkit/test/e2e/user-journeys/u-haul-workflow.e2e.test.ts index 262b41e..47393bd 100644 --- a/packages/toolkit/test/e2e/user-journeys/u-haul-workflow.e2e.test.ts +++ b/packages/toolkit/test/e2e/user-journeys/u-haul-workflow.e2e.test.ts @@ -105,6 +105,13 @@ args = ["@modelcontextprotocol/server-browser"] [agents.researcher] description = "Research tasks" developer_instructions = "Find relevant sources and summarize" + +[[hooks.PreToolUse]] + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 scripts/check-bash.py" +timeout = 30 `.trimStart(), "utf8", ); @@ -214,7 +221,7 @@ developer_instructions = "Find relevant sources and summarize" assert.equal(initPayload.data.uHaul.detected.subagent, 3); assert.equal(initPayload.data.uHaul.detected.settings, 3); assert.equal(initPayload.data.uHaul.detected.command, 2); - assert.equal(initPayload.data.uHaul.detected.hook, 0); + assert.equal(initPayload.data.uHaul.detected.hook, 1); assert.equal(initPayload.data.uHaul.imported.prompt, 1); assert.equal(initPayload.data.uHaul.imported.skill, 1); @@ -222,7 +229,7 @@ developer_instructions = "Find relevant sources and summarize" assert.equal(initPayload.data.uHaul.imported.subagent, 1); assert.equal(initPayload.data.uHaul.imported.settings, 3); assert.equal(initPayload.data.uHaul.imported.command, 1); - assert.equal(initPayload.data.uHaul.imported.hook, 0); + assert.equal(initPayload.data.uHaul.imported.hook, 1); assert.ok( initPayload.data.uHaul.precedenceDrops.some( @@ -280,6 +287,7 @@ developer_instructions = "Find relevant sources and summarize" await assert.doesNotReject(async () => fs.stat(path.join(workspace, ".harness/src/mcp/browser.json"))); await assert.doesNotReject(async () => fs.stat(path.join(workspace, ".harness/src/mcp/localdocs.json"))); await assert.doesNotReject(async () => fs.stat(path.join(workspace, ".harness/src/subagents/researcher.md"))); + await assert.doesNotReject(async () => fs.stat(path.join(workspace, ".harness/src/hooks/pre_tool_use.json"))); await assert.doesNotReject(async () => fs.stat(path.join(workspace, ".harness/src/settings/codex.toml"))); await assert.doesNotReject(async () => fs.stat(path.join(workspace, ".harness/src/settings/claude.json"))); await assert.doesNotReject(async () => fs.stat(path.join(workspace, ".harness/src/settings/copilot.json"))); @@ -299,6 +307,10 @@ developer_instructions = "Find relevant sources and summarize" assert.match(codexConfig, /\[mcp_servers\.browser\]/u); assert.match(codexConfig, /\[mcp_servers\.localdocs\]/u); assert.match(codexConfig, /\[agents\.researcher\]/u); + assert.match(codexConfig, /hooks = true/u); + assert.doesNotMatch(codexConfig, /codex_hooks/u); + assert.match(codexConfig, /\[\[hooks\.PreToolUse\]\]/u); + assert.match(codexConfig, /timeout = 30/u); // MCP JSON conventions per provider. const claudeMcp = await readWorkspaceJson<{ mcpServers: Record }>(workspace, ".mcp.json"); diff --git a/packages/toolkit/test/hooks.test.ts b/packages/toolkit/test/hooks.test.ts index 1a53e82..f26405a 100644 --- a/packages/toolkit/test/hooks.test.ts +++ b/packages/toolkit/test/hooks.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { parseCanonicalHookDocument } from "../src/hooks.ts"; +import { nativeToCanonicalHookEvent } from "../src/provider-adapters/hook-capabilities.ts"; import { renderClaudeHookSettings, renderCodexHookConfigObject, @@ -100,6 +101,28 @@ test("parseCanonicalHookDocument rejects non-string env values", () => { assert.ok(parsed.diagnostics.some((diagnostic) => diagnostic.code === "HOOK_ENV_INVALID")); }); +test("parseCanonicalHookDocument validates optional statusMessage", () => { + const parsed = parseCanonicalHookDocument( + { + mode: "strict", + events: { + pre_tool_use: [ + { + type: "command", + command: "echo pre-tool", + statusMessage: "", + }, + ], + }, + }, + ".harness/src/hooks/guard.json", + "guard", + ); + + assert.equal(parsed.canonical, undefined); + assert.ok(parsed.diagnostics.some((diagnostic) => diagnostic.code === "HOOK_STATUS_MESSAGE_INVALID")); +}); + test("resolveHookTargetPath returns explicit hook override target", () => { const overrideByEntity = new Map([ [ @@ -316,7 +339,7 @@ test("resolveCodexNotifyCommand ignores unsupported events in best_effort mode", assert.deepEqual(resolveCodexNotifyCommand(hooks), ["python3", "scripts/on_turn_complete.py"]); }); -test("renderCodexHookConfigObject maps supported events and enables the feature flag", () => { +test("renderCodexHookConfigObject maps all supported lifecycle events and enables the canonical feature flag", () => { const hooks: CanonicalHook[] = [ { id: "guard", @@ -329,12 +352,33 @@ test("renderCodexHookConfigObject maps supported events and enables the feature matcher: "startup|resume", }, ], + prompt_submit: [ + { + type: "command", + command: "echo prompt", + }, + ], pre_tool_use: [ { type: "command", command: "echo pre-tool", matcher: "^Bash$", timeoutSec: 30, + statusMessage: "Checking Bash command", + }, + ], + permission_request: [ + { + type: "command", + command: "echo permission-request", + matcher: "^Bash$", + }, + ], + post_tool_use: [ + { + type: "command", + command: "echo post-tool", + matcher: "^Bash$", }, ], stop: [ @@ -348,17 +392,49 @@ test("renderCodexHookConfigObject maps supported events and enables the feature ]; const rendered = renderCodexHookConfigObject(hooks) as { - features?: { codex_hooks?: boolean }; + features?: { hooks?: boolean; codex_hooks?: boolean }; hooks?: Record> }>>; }; - assert.equal(rendered.features?.codex_hooks, true); + assert.equal(rendered.features?.hooks, true); + assert.equal(rendered.features?.codex_hooks, undefined); assert.equal(rendered.hooks?.SessionStart?.[0]?.matcher, "startup|resume"); + assert.ok(rendered.hooks?.UserPromptSubmit); assert.equal(rendered.hooks?.PreToolUse?.[0]?.matcher, "^Bash$"); assert.equal(rendered.hooks?.PreToolUse?.[0]?.hooks?.[0]?.timeout, 30); + assert.equal(rendered.hooks?.PreToolUse?.[0]?.hooks?.[0]?.statusMessage, "Checking Bash command"); + assert.ok(rendered.hooks?.PermissionRequest); + assert.ok(rendered.hooks?.PostToolUse); assert.ok(rendered.hooks?.Stop); }); +test("hook capabilities reverse-map native Codex and Claude events", () => { + assert.equal(nativeToCanonicalHookEvent("codex", "PermissionRequest"), "permission_request"); + assert.equal(nativeToCanonicalHookEvent("codex", "SessionEnd"), undefined); + assert.equal(nativeToCanonicalHookEvent("claude", "SessionEnd"), "session_end"); + assert.equal(nativeToCanonicalHookEvent("copilot", "preCompact"), "pre_compact"); +}); + +test("non-Codex providers reject statusMessage in strict mode", () => { + const hook: CanonicalHook = { + id: "status", + mode: "strict", + events: { + pre_tool_use: [ + { + type: "command", + command: "echo status", + statusMessage: "Checking", + }, + ], + }, + }; + + assert.throws(() => renderClaudeHookSettings([hook]), /HOOK_EVENT_UNSUPPORTED/u); + assert.throws(() => renderCopilotHookConfig([hook]), /HOOK_EVENT_UNSUPPORTED/u); + assert.throws(() => renderCursorHookConfig([hook]), /HOOK_EVENT_UNSUPPORTED/u); +}); + test("renderCodexHookConfigObject throws for unsupported events in strict mode", () => { const hooks: CanonicalHook[] = [ { diff --git a/packages/toolkit/test/providers.test.ts b/packages/toolkit/test/providers.test.ts index 6ec1152..05a8732 100644 --- a/packages/toolkit/test/providers.test.ts +++ b/packages/toolkit/test/providers.test.ts @@ -845,6 +845,27 @@ test("codex provider renders documented hook events into config.toml", async () command: "echo pre-tool", matcher: "^Bash$", timeoutSec: 45, + statusMessage: "Checking Bash command", + }, + ], + permission_request: [ + { + type: "command", + command: "echo permission", + matcher: "^Bash$", + }, + ], + post_tool_use: [ + { + type: "command", + command: "echo post-tool", + matcher: "^Bash$", + }, + ], + prompt_submit: [ + { + type: "command", + command: "echo prompt", }, ], stop: [ @@ -870,13 +891,18 @@ test("codex provider renders documented hook events into config.toml", async () const tomlContent = await fs.readFile(path.join(cwd, ".codex/config.toml"), "utf8"); assert.match(tomlContent, /\[features\]/u); - assert.match(tomlContent, /codex_hooks = true/u); + assert.match(tomlContent, /hooks = true/u); + assert.doesNotMatch(tomlContent, /codex_hooks/u); assert.match(tomlContent, /\[\[hooks\.SessionStart\]\]/u); assert.match(tomlContent, /matcher = "startup\|resume"/u); + assert.match(tomlContent, /\[\[hooks\.UserPromptSubmit\]\]/u); assert.match(tomlContent, /\[\[hooks\.PreToolUse\]\]/u); assert.match(tomlContent, /matcher = "\^Bash\$"/u); assert.match(tomlContent, /\[\[hooks\.PreToolUse\.hooks\]\]/u); assert.match(tomlContent, /timeout = 45/u); + assert.match(tomlContent, /statusMessage = "Checking Bash command"/u); + assert.match(tomlContent, /\[\[hooks\.PermissionRequest\]\]/u); + assert.match(tomlContent, /\[\[hooks\.PostToolUse\]\]/u); assert.match(tomlContent, /\[\[hooks\.Stop\]\]/u); }); diff --git a/packages/toolkit/test/u-haul.test.ts b/packages/toolkit/test/u-haul.test.ts index abc6023..3ecd030 100644 --- a/packages/toolkit/test/u-haul.test.ts +++ b/packages/toolkit/test/u-haul.test.ts @@ -127,6 +127,147 @@ test("runUHaulInitFlow imports all entity families, deletes legacy paths, enable await assert.doesNotReject(async () => fs.stat(path.join(cwd, ".claude/skills/reviewer/SKILL.md"))); }); +test("runUHaulInitFlow imports Codex inline lifecycle hooks and normalizes the feature flag", async () => { + const cwd = await mkTmpRepo(); + await initGitRepo(cwd); + + await fs.mkdir(path.join(cwd, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".codex/config.toml"), + ` +[features] +codex_hooks = true + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 scripts/check-bash.py" +timeout = 30 +statusMessage = "Checking Bash command" +`.trimStart(), + "utf8", + ); + + const summary = await runUHaulInitFlow({ cwd, force: false }); + + assert.equal(summary.detected.hook, 1); + assert.equal(summary.imported.hook, 1); + assert.deepEqual(summary.autoEnabledProviders, ["codex"]); + assert.ok(summary.deletedLegacyPaths.includes(".codex/config.toml")); + + const canonicalHook = JSON.parse( + await fs.readFile(path.join(cwd, ".harness/src/hooks/pre_tool_use.json"), "utf8"), + ) as { + events?: { pre_tool_use?: Array<{ statusMessage?: string; matcher?: string }> }; + }; + assert.equal(canonicalHook.events?.pre_tool_use?.[0]?.matcher, "^Bash$"); + assert.equal(canonicalHook.events?.pre_tool_use?.[0]?.statusMessage, "Checking Bash command"); + + const rendered = await fs.readFile(path.join(cwd, ".codex/config.toml"), "utf8"); + assert.match(rendered, /hooks = true/u); + assert.doesNotMatch(rendered, /codex_hooks/u); + assert.match(rendered, /statusMessage = "Checking Bash command"/u); +}); + +test("runUHaulInitFlow imports standalone Codex hooks.json", async () => { + const cwd = await mkTmpRepo(); + await initGitRepo(cwd); + + await fs.mkdir(path.join(cwd, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".codex/hooks.json"), + JSON.stringify( + { + hooks: { + PermissionRequest: [ + { + matcher: "^Bash$", + hooks: [ + { + type: "command", + command: "python3 scripts/permission.py", + statusMessage: "Checking approval request", + }, + { + type: "prompt", + prompt: "parsed but skipped by Codex", + }, + ], + }, + ], + }, + }, + null, + 2, + ), + "utf8", + ); + + const summary = await runUHaulInitFlow({ cwd, force: false }); + + assert.equal(summary.detected.hook, 1); + assert.equal(summary.imported.hook, 1); + assert.ok(summary.deletedLegacyPaths.includes(".codex/hooks.json")); + + const canonicalHook = JSON.parse( + await fs.readFile(path.join(cwd, ".harness/src/hooks/permission_request.json"), "utf8"), + ) as { + events?: { permission_request?: Array<{ command?: string; statusMessage?: string }> }; + }; + assert.equal(canonicalHook.events?.permission_request?.length, 1); + assert.equal(canonicalHook.events?.permission_request?.[0]?.command, "python3 scripts/permission.py"); + assert.equal(canonicalHook.events?.permission_request?.[0]?.statusMessage, "Checking approval request"); +}); + +test("runUHaulInitFlow drops Codex matchers for events that ignore them", async () => { + const cwd = await mkTmpRepo(); + await initGitRepo(cwd); + + await fs.mkdir(path.join(cwd, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".codex/hooks.json"), + JSON.stringify( + { + hooks: { + UserPromptSubmit: [ + { + matcher: "ignored-by-codex", + hooks: [ + { + type: "command", + command: "python3 scripts/prompt.py", + }, + ], + }, + ], + }, + }, + null, + 2, + ), + "utf8", + ); + + const summary = await runUHaulInitFlow({ cwd, force: false }); + + assert.equal(summary.detected.hook, 1); + assert.equal(summary.imported.hook, 1); + + const canonicalHook = JSON.parse( + await fs.readFile(path.join(cwd, ".harness/src/hooks/prompt_submit.json"), "utf8"), + ) as { + events?: { prompt_submit?: Array<{ command?: string; matcher?: string }> }; + }; + assert.equal(canonicalHook.events?.prompt_submit?.[0]?.command, "python3 scripts/prompt.py"); + assert.equal(canonicalHook.events?.prompt_submit?.[0]?.matcher, undefined); + + const rendered = await fs.readFile(path.join(cwd, ".codex/config.toml"), "utf8"); + assert.match(rendered, /UserPromptSubmit/u); + assert.doesNotMatch(rendered, /ignored-by-codex/u); +}); + test("runUHaulInitFlow resolves prompt conflicts by default precedence and supports precedence override", async () => { const cwdDefault = await mkTmpRepo(); await initGitRepo(cwdDefault);