From 22fbfa22536480d9fecdf9d29c494d6057be3c9c Mon Sep 17 00:00:00 2001 From: Boden Fuller Date: Wed, 22 Apr 2026 18:32:33 -0400 Subject: [PATCH] fix(codex): repair native hook manifest install --- cli/cmd/ao/codex_runtime.go | 123 +++++++++++++- cli/cmd/ao/codex_runtime_test.go | 85 ++++++++++ docs/architecture/codex-hookless-lifecycle.md | 2 +- docs/contracts/hook-runtime-contract.md | 43 ++--- docs/knowledge-flywheel.md | 2 +- docs/workflows/session-lifecycle.md | 2 +- hooks/codex-hooks.json | 135 ++++++++------- scripts/install-codex-plugin.sh | 72 +++++++- scripts/install-codex.ps1 | 154 +++++++++++++++++- scripts/test-codex-native-install.sh | 22 ++- tests/skills/test-runtime-codex-smoke.sh | 34 ++-- 11 files changed, 551 insertions(+), 123 deletions(-) diff --git a/cli/cmd/ao/codex_runtime.go b/cli/cmd/ao/codex_runtime.go index 46f18aa21..fb4f8806b 100644 --- a/cli/cmd/ao/codex_runtime.go +++ b/cli/cmd/ao/codex_runtime.go @@ -21,6 +21,8 @@ const ( lifecycleModeHookCapable = "hook-capable" lifecycleModeCodexHookless = "codex-hookless-fallback" lifecycleModeManual = "manual" + + codexNativeHooksMinVersion = "0.115.0" ) var codexArchivedSessionPattern = regexp.MustCompile(`([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$`) @@ -73,6 +75,8 @@ func detectLifecycleRuntimeProfileWithOptions(forceCodex bool) lifecycleRuntimeP claudeManifest := filepath.Join(homeDir, ".agentops", "hooks.json") legacyClaudeManifest := filepath.Join(homeDir, ".claude", "hooks.json") + codexConfig := filepath.Join(homeDir, ".codex", "config.toml") + codexManifest := filepath.Join(homeDir, ".codex", "hooks.json") openCodeManifest := filepath.Join(homeDir, ".config", "opencode", "agentops", "hooks", "hooks.json") runtimeKind := detectRuntimeKind(forceCodex) @@ -84,13 +88,30 @@ func detectLifecycleRuntimeProfileWithOptions(forceCodex bool) lifecycleRuntimeP switch runtimeKind { case runtimeKindCodex: profile.Mode = lifecycleModeCodexHookless - profile.HookCapable = false - profile.HookConfigured = false profile.SessionID = resolveCodexSessionID(homeDir) if entry, err := readCodexSessionIndexEntry(homeDir, profile.SessionID); err == nil && entry != nil { profile.ThreadName = strings.TrimSpace(entry.ThreadName) } - profile.Reason = "Codex does not expose the Claude/OpenCode hook lifecycle under ~/.codex; use ao codex start/stop for explicit lifecycle handling." + profile.HookCapable = codexSupportsNativeHooks(homeDir) + if profile.HookCapable { + manifestConfigured, manifestReason := codexHooksManifestConfigured(codexManifest) + featureEnabled := codexHooksFeatureEnabled(codexConfig) + switch { + case featureEnabled && manifestConfigured: + profile.Mode = lifecycleModeHookCapable + profile.HookConfigured = true + profile.HookManifestPath = codexManifest + profile.Reason = "Codex native hooks are configured via ~/.codex/hooks.json." + case manifestConfigured && !featureEnabled: + profile.Reason = "Codex native hooks are installed, but [features].codex_hooks is disabled in ~/.codex/config.toml; use ao codex start/stop until the feature flag is re-enabled." + case featureEnabled && !manifestConfigured: + profile.Reason = manifestReason + default: + profile.Reason = "Codex native hooks are supported but not configured; install them or use ao codex start/stop for explicit lifecycle handling." + } + } else { + profile.Reason = "Detected Codex runtime without native hook support; use ao codex start/stop for explicit lifecycle handling." + } case runtimeKindClaude: profile.HookCapable = true profile.SessionID = canonicalSessionID(strings.TrimSpace(os.Getenv("CLAUDE_SESSION_ID"))) @@ -132,6 +153,102 @@ func detectLifecycleRuntimeProfileWithOptions(forceCodex bool) lifecycleRuntimeP return profile } +func codexSupportsNativeHooks(homeDir string) bool { + if version, ok := readCodexLatestVersion(homeDir); ok { + return compareSemver(version, codexNativeHooksMinVersion) >= 0 + } + return codexHooksFeatureEnabled(filepath.Join(homeDir, ".codex", "config.toml")) || + fileExists(filepath.Join(homeDir, ".codex", "hooks.json")) +} + +func readCodexLatestVersion(homeDir string) (string, bool) { + path := filepath.Join(homeDir, ".codex", "version.json") + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + + var payload struct { + LatestVersion string `json:"latest_version"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return "", false + } + + version := normalizeSemver(payload.LatestVersion) + if version == "" { + return "", false + } + return version, true +} + +func codexHooksFeatureEnabled(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + + inFeatures := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, "[") { + inFeatures = trimmed == "[features]" + continue + } + if inFeatures && strings.HasPrefix(trimmed, "codex_hooks") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + return false + } + return strings.EqualFold(strings.TrimSpace(parts[1]), "true") + } + } + + return false +} + +func codexHooksManifestConfigured(path string) (bool, string) { + data, err := os.ReadFile(path) + if err != nil { + return false, "Codex native hooks are supported, but ~/.codex/hooks.json is missing; use ao codex start/stop until hooks are installed." + } + + var payload struct { + Hooks json.RawMessage `json:"hooks"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return false, fmt.Sprintf("~/.codex/hooks.json is invalid JSON: %v", err) + } + if len(payload.Hooks) == 0 { + return false, "~/.codex/hooks.json is missing the hooks event map." + } + + var hooksMap map[string]json.RawMessage + if err := json.Unmarshal(payload.Hooks, &hooksMap); err != nil { + return false, "~/.codex/hooks.json uses an unsupported hooks shape; expected an event map under hooks." + } + if len(hooksMap) == 0 { + return false, "~/.codex/hooks.json has an empty hooks event map." + } + + return true, "" +} + +func normalizeSemver(raw string) string { + version := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "v")) + if version == "" { + return "" + } + version = strings.SplitN(version, "-", 2)[0] + if version == "" { + return "" + } + return version +} + func detectRuntimeKind(forceCodex bool) string { if forceCodex { return runtimeKindCodex diff --git a/cli/cmd/ao/codex_runtime_test.go b/cli/cmd/ao/codex_runtime_test.go index 826506ba4..0b458c456 100644 --- a/cli/cmd/ao/codex_runtime_test.go +++ b/cli/cmd/ao/codex_runtime_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" "time" ) @@ -434,6 +435,90 @@ func TestDetectLifecycleRuntimeProfileWithOptions_Codex(t *testing.T) { } } +func TestDetectLifecycleRuntimeProfileWithOptions_Codex_WithNativeHooksConfigured(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CODEX_THREAD_ID", "codex-thread-native") + t.Setenv("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "") + t.Setenv("CODEX_CI", "") + t.Setenv("CLAUDE_SESSION_ID", "") + t.Setenv("OPENCODE_SESSION_ID", "") + + codexDir := filepath.Join(home, ".codex") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexDir, "version.json"), []byte(`{"latest_version":"0.122.0"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexDir, "config.toml"), []byte("[features]\ncodex_hooks = true\n"), 0o644); err != nil { + t.Fatal(err) + } + manifest := `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"bash /tmp/session-start.sh"}]}]}}` + if err := os.WriteFile(filepath.Join(codexDir, "hooks.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + + profile := detectLifecycleRuntimeProfileWithOptions(false) + if profile.Runtime != runtimeKindCodex { + t.Errorf("Runtime = %q, want %q", profile.Runtime, runtimeKindCodex) + } + if profile.Mode != lifecycleModeHookCapable { + t.Errorf("Mode = %q, want %q", profile.Mode, lifecycleModeHookCapable) + } + if !profile.HookCapable { + t.Error("HookCapable should be true when native Codex hooks are supported") + } + if !profile.HookConfigured { + t.Error("HookConfigured should be true with a valid native Codex hook manifest") + } + if got := profile.HookManifestPath; got != filepath.Join(codexDir, "hooks.json") { + t.Errorf("HookManifestPath = %q, want %q", got, filepath.Join(codexDir, "hooks.json")) + } +} + +func TestDetectLifecycleRuntimeProfileWithOptions_Codex_WithLegacyFlatManifest(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CODEX_THREAD_ID", "codex-thread-legacy") + t.Setenv("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "") + t.Setenv("CODEX_CI", "") + t.Setenv("CLAUDE_SESSION_ID", "") + t.Setenv("OPENCODE_SESSION_ID", "") + + codexDir := filepath.Join(home, ".codex") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexDir, "version.json"), []byte(`{"latest_version":"0.122.0"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexDir, "config.toml"), []byte("[features]\ncodex_hooks = true\n"), 0o644); err != nil { + t.Fatal(err) + } + legacyManifest := `{"hooks":[{"name":"agentops-session-start","event":"SessionStart","command":"bash /tmp/session-start.sh","timeout":10000}]}` + if err := os.WriteFile(filepath.Join(codexDir, "hooks.json"), []byte(legacyManifest), 0o644); err != nil { + t.Fatal(err) + } + + profile := detectLifecycleRuntimeProfileWithOptions(false) + if profile.Runtime != runtimeKindCodex { + t.Errorf("Runtime = %q, want %q", profile.Runtime, runtimeKindCodex) + } + if profile.Mode != lifecycleModeCodexHookless { + t.Errorf("Mode = %q, want %q", profile.Mode, lifecycleModeCodexHookless) + } + if !profile.HookCapable { + t.Error("HookCapable should remain true when the installed Codex version supports native hooks") + } + if profile.HookConfigured { + t.Error("HookConfigured should be false for the legacy flat hooks.json shape") + } + if !strings.Contains(profile.Reason, "unsupported hooks shape") { + t.Errorf("Reason = %q, want unsupported hooks shape guidance", profile.Reason) + } +} + func TestExtractSessionIDFromCodexArchivedPath(t *testing.T) { tests := []struct { name string diff --git a/docs/architecture/codex-hookless-lifecycle.md b/docs/architecture/codex-hookless-lifecycle.md index 73cb99517..52b29285d 100644 --- a/docs/architecture/codex-hookless-lifecycle.md +++ b/docs/architecture/codex-hookless-lifecycle.md @@ -1,6 +1,6 @@ # Codex Hookless Lifecycle -> **Note:** This document describes the legacy hookless fallback for Codex versions prior to v0.115.0. Starting with Codex v0.115.0 (March 2026), native hooks are supported and installed by `scripts/install-codex-plugin.sh`. The `codex-native-hooks` runtime provides the same hook surface as Claude Code. The hookless fallback documented here remains available for older Codex versions that lack native hook support. +> **Note:** This document describes the legacy hookless fallback for Codex versions prior to v0.115.0. Starting with Codex v0.115.0 (March 2026), native hooks are supported and installed by `scripts/install-codex-plugin.sh`. Current Codex native hooks cover `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, and `PermissionRequest`, but not `SessionEnd`, so the hookless fallback and explicit closeout path still matter when transcript-driven maintenance is required. AgentOps originally assumed a hook-capable runtime lifecycle such as Claude/OpenCode `session-start`, `session-end`, and `stop`. Codex Desktop does not expose that lifecycle surface under `~/.codex`, so the Codex runtime needs an explicit fallback that keeps the flywheel working without pretending hooks exist. diff --git a/docs/contracts/hook-runtime-contract.md b/docs/contracts/hook-runtime-contract.md index 8f6ba17a8..9a04d520e 100644 --- a/docs/contracts/hook-runtime-contract.md +++ b/docs/contracts/hook-runtime-contract.md @@ -8,33 +8,22 @@ | Runtime | Detection | Hook Surface | |---------|-----------|-------------| | Claude Code | `CLAUDE_PLUGIN_ROOT` env or `~/.claude/settings.json` exists | Full hook manifest (`hooks.json`) | -| Codex (native hooks) | `CODEX_HOME` env + Codex v0.115.0+ detected | Full hook manifest via `scripts/install-codex-plugin.sh` (same surface as Claude Code) | -| Codex (hookless fallback) | `CODEX_HOME` env or `~/.codex/config.toml` exists + Codex < v0.115.0 | Explicit lifecycle (`ao codex start/stop`) | +| Codex (native hooks) | `CODEX_HOME` env plus `~/.codex/version.json` / `config.toml` / `hooks.json` indicate native hooks are supported and enabled | Native hook manifest via `scripts/install-codex-plugin.sh` (`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PermissionRequest`) | +| Codex (hookless fallback) | `CODEX_HOME` env or `~/.codex/config.toml` exists but native hooks are unavailable or not configured | Explicit lifecycle (`ao codex start/stop`) | | Manual | Neither detected | Explicit `ao inject` / `ao forge` commands | ## Event Mapping -### Supported (native equivalent exists) - -| Claude Event | Codex Equivalent | Mechanism | -|-------------|-----------------|-----------| -| `SessionStart` | `ao codex start` / `ao codex ensure-start` | Claude native hooks can recover a goal and stage runtime state silently; Codex uses the explicit startup command path | -| `SessionEnd` | `ao codex stop` / `ao codex ensure-stop` | Explicit command in skill epilogue or user invocation | -| `Stop` | `ao codex stop --close-loop` | Explicit flywheel close via command | - -### Adapted (behavior preserved, different mechanism) - -| Claude Event | Codex Adaptation | Notes | -|-------------|-----------------|-------| -| `UserPromptSubmit` | Skill preamble checks | Claude native hooks can capture first-prompt intake silently, emit prompt nudges, and run intent echo; Codex keeps equivalent guidance inside skill/runtime preambles instead of callbacks | -| `TaskCompleted` | Skill epilogue validation | Task validation gates run inline after task completion | - -### Unsupported (no Codex equivalent) - -| Claude Event | Codex Status | Fallback | -|-------------|-------------|----------| -| `PreToolUse` | No tool-intercept surface | Validation moves to skill-level checks or pre-commit gates | -| `PostToolUse` | No tool-intercept surface | Quality checks move to skill-level or post-edit inline checks | +| Capability | Claude/OpenCode hook-capable | Codex native hooks (v0.115.0+) | Codex hookless fallback | +|-----------|------------------------------|------------------------------|------------------------| +| `SessionStart` | Native `SessionStart` hook | Native `SessionStart` hook | `ao codex start` / `ao codex ensure-start` | +| `UserPromptSubmit` | Native `UserPromptSubmit` hook | Native `UserPromptSubmit` hook | Skill/runtime preambles | +| `PreToolUse` | Native `PreToolUse` hook | Native `PreToolUse` hook (currently Bash-only) | No runtime hook; validation moves inline or to pre-commit gates | +| `PostToolUse` | Native `PostToolUse` hook | Native `PostToolUse` hook (currently Bash-only) | No runtime hook; quality checks move inline or post-edit | +| `Stop` | Native `Stop` hook | Native `Stop` hook | `ao codex stop` / `ao codex ensure-stop` | +| `SessionEnd` | Native `SessionEnd` hook | No native `SessionEnd` event; transcript-driven closeout still uses `ao codex stop` / `ao codex ensure-stop` | `ao codex stop` / `ao codex ensure-stop` | +| `PermissionRequest` | Native `PermissionRequest` hook | Native `PermissionRequest` hook | Approval handled explicitly by the runtime/workflow | +| `TaskCompleted` | Native `TaskCompleted` hook | No native event; task validation remains skill-driven | Skill epilogue validation | ## Install Behavior by Runtime @@ -55,12 +44,12 @@ ao hook install # Merges hooks.json into ~/.claude/settings.json # Plugin install (handled by install-codex-plugin.sh): # 1. Enable plugin in ~/.codex/config.toml # 2. Stage skills-codex/ bundle -# 3. Install native hook manifest — same event surface as Claude Code +# 3. Install native hook manifest for the current Codex event set ``` -- Full hook manifest installed by `scripts/install-codex-plugin.sh` -- SessionStart, SessionEnd, Stop events wired natively -- Same automatic lifecycle as Claude Code +- Native hook manifest installed from `hooks/codex-hooks.json` +- Current Codex-native surface: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, and `PermissionRequest` +- Transcript-driven closeout still uses `ao codex stop` / `ao codex ensure-stop` because Codex does not expose a native `SessionEnd` event today ### Codex (pre-v0.115.0 — hookless fallback) diff --git a/docs/knowledge-flywheel.md b/docs/knowledge-flywheel.md index 95cc35d98..1c20d4b6b 100644 --- a/docs/knowledge-flywheel.md +++ b/docs/knowledge-flywheel.md @@ -22,7 +22,7 @@ AgentOps turns session output into durable environment state. The automation pat | Mode | Start path | Closeout path | What is automatic | |------|------------|---------------|-------------------| | Hook-capable runtime | SessionStart hook or `ao inject` | SessionEnd/Stop hooks or `ao forge transcript` + `ao flywheel close-loop` | Startup retrieval, transcript forging, pool maintenance when hooks are installed | -| Codex native hooks (v0.115.0+) | Native SessionStart hook via `scripts/install-codex-plugin.sh` | Native SessionEnd/Stop hooks | Same automatic lifecycle as Claude Code; installed by `scripts/install-codex-plugin.sh` | +| Codex native hooks (v0.115.0+) | Native `SessionStart` hook via `scripts/install-codex-plugin.sh` | Native `Stop` hook plus explicit `ao codex stop` when transcript-driven closeout is needed | Startup retrieval, prompt/tool guardrails, and turn-scope close-loop are automatic; `SessionEnd` remains explicit because Codex has no native `SessionEnd` event today | | Codex hookless fallback (pre-v0.115.0) | `ao codex start` | `ao codex stop` | Startup context assembly, transcript discovery fallback, citation capture, and close-loop status through explicit commands | | Manual fallback | `ao inject` / `ao lookup` | `ao forge transcript` + `ao flywheel close-loop` | Nothing hidden; operator runs the lifecycle directly | diff --git a/docs/workflows/session-lifecycle.md b/docs/workflows/session-lifecycle.md index c99cf861a..bf7b4b922 100644 --- a/docs/workflows/session-lifecycle.md +++ b/docs/workflows/session-lifecycle.md @@ -109,7 +109,7 @@ SessionEnd would normally run. | Mode | Start | Closeout | Notes | |------|-------|----------|-------| | Hook-capable | Natural language, `/session-start`, or startup hooks | Natural language, `/session-end`, or session-end hooks | Best fit for Claude/OpenCode when hooks are installed; `CLAUDE.md` is the startup surface and hooks stage state silently | -| Codex native hooks (v0.115.0+) | Same as hook-capable — native hooks installed by `scripts/install-codex-plugin.sh` | Same as hook-capable — native SessionEnd/Stop hooks | Full hook surface; same automatic lifecycle as Claude Code | +| Codex native hooks (v0.115.0+) | Same startup path as hook-capable — native hooks installed by `scripts/install-codex-plugin.sh` | Native `Stop` hook for turn-scope close-loop; explicit `ao codex stop` / `ao codex ensure-stop` for transcript-driven closeout | Native `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, and `PermissionRequest`; no native `SessionEnd` event today | | Codex hookless fallback (pre-v0.115.0) | `ao factory start --goal ""`, `ao codex start`, or skill-driven `ao codex ensure-start` | `ao codex stop` or skill-driven `ao codex ensure-stop` | No startup/session-end hook surface under `~/.codex`; lifecycle is explicit, and closeout owns the same curation hygiene as SessionEnd | | Dream overnight run | `ao overnight setup --apply` then `ao overnight start --goal ""` | `ao overnight report --from ` | Private local overnight mode. Dream bootstraps config and scheduler assistance, while the host OS still owns actual scheduling semantics | | Manual fallback | `ao inject`, `ao lookup` | `ao forge transcript`, `ao flywheel close-loop` | Lowest-level portable path | diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json index 168375e81..7e3f425e0 100644 --- a/hooks/codex-hooks.json +++ b/hooks/codex-hooks.json @@ -1,63 +1,78 @@ { "$schema": "../schemas/hooks-manifest.v1.schema.json", - "hooks": [ - { - "name": "agentops-session-start", - "event": "SessionStart", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/session-start.sh", - "timeout": 10000 - }, - { - "name": "agentops-inject", - "event": "SessionStart", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/ao-inject.sh", - "timeout": 10000 - }, - { - "name": "agentops-flywheel-close", - "event": "Stop", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/ao-flywheel-close.sh", - "timeout": 15000 - }, - { - "name": "agentops-prompt-nudge", - "event": "UserPromptSubmit", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/prompt-nudge.sh", - "timeout": 5000 - }, - { - "name": "agentops-quality-signals", - "event": "UserPromptSubmit", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/quality-signals.sh", - "timeout": 3000 - }, - { - "name": "agentops-go-test-precommit", - "event": "PreToolUse", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/go-test-precommit.sh", - "matcher": { - "type": "bash" - }, - "timeout": 60000 - }, - { - "name": "agentops-commit-review", - "event": "PreToolUse", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/commit-review-gate.sh", - "matcher": { - "type": "bash", - "pattern": "git commit" - }, - "timeout": 10000 - }, - { - "name": "agentops-ratchet-advance", - "event": "PostToolUse", - "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/ratchet-advance.sh", - "matcher": { - "type": "bash" - }, - "timeout": 5000 - } - ] + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/session-start.sh", + "timeout": 10 + }, + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/ao-inject.sh", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/ao-flywheel-close.sh", + "timeout": 15 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/prompt-nudge.sh", + "timeout": 5 + }, + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/quality-signals.sh", + "timeout": 3 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/go-test-precommit.sh", + "timeout": 60 + }, + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/commit-review-gate.sh", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash ${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}/hooks/ratchet-advance.sh", + "timeout": 5 + } + ] + } + ] + } } diff --git a/scripts/install-codex-plugin.sh b/scripts/install-codex-plugin.sh index eb827ba51..5ea118902 100755 --- a/scripts/install-codex-plugin.sh +++ b/scripts/install-codex-plugin.sh @@ -231,6 +231,60 @@ upsert_toml_key() { mv "$tmp" "$file" } +count_codex_hook_handlers() { + local path="$1" + + jq -r '[.hooks | to_entries[]? | .value[]? | .hooks[]?] | length' "$path" +} + +merge_codex_hooks() { + local existing_file="$1" + local new_file="$2" + + jq -n \ + --slurpfile existing "$existing_file" \ + --slurpfile new "$new_file" \ + ' + def agentops_scripts: + [ + "session-start.sh", + "ao-inject.sh", + "ao-flywheel-close.sh", + "prompt-nudge.sh", + "quality-signals.sh", + "go-test-precommit.sh", + "commit-review-gate.sh", + "ratchet-advance.sh" + ]; + def is_agentops_command($cmd): + ($cmd | type == "string") and any(agentops_scripts[]; $cmd | endswith("/hooks/" + .)); + def strip_agentops_groups: + if (.hooks | type? == "object") then + .hooks |= with_entries( + .value |= [ + .[]? + | .hooks = [ + .hooks[]? + | select(is_agentops_command(.command // "") | not) + ] + | select((.hooks | length) > 0) + ] + ) + | .hooks |= with_entries(select((.value | length) > 0)) + else + .hooks = {} + end; + ($existing[0] // {}) as $existing_doc + | ($new[0] // {}) as $new_doc + | ($existing_doc | strip_agentops_groups) as $cleaned + | ($new_doc."$schema" // $existing_doc."$schema") as $schema + | { + "$schema": $schema, + "hooks": (($cleaned.hooks // {}) + ($new_doc.hooks // {})) + } + ' > "$existing_file" +} + stage_plugin_source() { local staging_root="$1" @@ -401,28 +455,32 @@ if [[ -f "${HOOKS_SRC}/codex-hooks.json" ]]; then fi # Install hooks.json to ~/.codex/hooks.json (merge if exists) - CODEX_HOOKS_FILE="${HOME}/.codex/hooks.json" + CODEX_HOOKS_FILE="${CODEX_HOME}/hooks.json" CODEX_HOOKS_SRC="${HOOKS_SRC}/codex-hooks.json" # Replace AGENTOPS_PLUGIN_ROOT with actual path RENDERED_HOOKS="$(sed "s|\${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}|${PLUGIN_CACHE_ROOT}|g" "$CODEX_HOOKS_SRC")" + RENDERED_HOOKS_FILE="${TMP_DIR}/rendered-codex-hooks.json" + printf '%s\n' "$RENDERED_HOOKS" > "$RENDERED_HOOKS_FILE" if [[ -f "$CODEX_HOOKS_FILE" ]]; then # Backup existing hooks cp "$CODEX_HOOKS_FILE" "${CODEX_HOOKS_FILE}.bak.$(date +%s)" - # Remove any existing agentops hooks, then merge - EXISTING="$(jq '[.hooks[] | select(.name | startswith("agentops-") | not)]' "$CODEX_HOOKS_FILE" 2>/dev/null || echo '[]')" - NEW_HOOKS="$(echo "$RENDERED_HOOKS" | jq '.hooks')" - jq -n --argjson existing "$EXISTING" --argjson new "$NEW_HOOKS" '{"hooks": ($existing + $new)}' > "$CODEX_HOOKS_FILE" + if jq -e '.hooks | type == "array"' "$CODEX_HOOKS_FILE" >/dev/null 2>&1; then + warn "Existing ~/.codex/hooks.json uses the legacy flat-array shape; replacing it with the current Codex event-map schema." + fi + merge_codex_hooks "$CODEX_HOOKS_FILE" "$RENDERED_HOOKS_FILE" else mkdir -p "$(dirname "$CODEX_HOOKS_FILE")" - echo "$RENDERED_HOOKS" | jq '.' > "$CODEX_HOOKS_FILE" + jq '.' "$RENDERED_HOOKS_FILE" > "$CODEX_HOOKS_FILE" fi # Enable hooks feature in config upsert_toml_key "$CONFIG_FILE" "[features]" "codex_hooks" "true" - info "Codex hooks installed ($(echo "$RENDERED_HOOKS" | jq '.hooks | length') hooks)" + HOOK_HANDLER_COUNT="$(count_codex_hook_handlers "$RENDERED_HOOKS_FILE")" + HOOK_EVENT_COUNT="$(jq -r '.hooks | length' "$RENDERED_HOOKS_FILE")" + info "Codex hooks installed (${HOOK_HANDLER_COUNT} handlers across ${HOOK_EVENT_COUNT} events)" echo " Hooks config: $CODEX_HOOKS_FILE" echo " Hook scripts: $HOOKS_DST/" else diff --git a/scripts/install-codex.ps1 b/scripts/install-codex.ps1 index c3e57a358..051d89607 100644 --- a/scripts/install-codex.ps1 +++ b/scripts/install-codex.ps1 @@ -187,6 +187,136 @@ function Archive-SkillRoot { return $null } +function Get-AgentOpsCodexHookScripts { + return @( + "session-start.sh", + "ao-inject.sh", + "ao-flywheel-close.sh", + "prompt-nudge.sh", + "quality-signals.sh", + "go-test-precommit.sh", + "commit-review-gate.sh", + "ratchet-advance.sh" + ) +} + +function Test-AgentOpsCodexHookCommand { + param([string]$Command) + + if ([string]::IsNullOrWhiteSpace($Command)) { + return $false + } + + foreach ($script in Get-AgentOpsCodexHookScripts) { + if ($Command -like "*/hooks/$script") { + return $true + } + } + + return $false +} + +function Get-CodexHookEventCount { + param($Manifest) + + if ($null -eq $Manifest -or $null -eq $Manifest.hooks) { + return 0 + } + + return @($Manifest.hooks.PSObject.Properties).Count +} + +function Get-CodexHookHandlerCount { + param($Manifest) + + $count = 0 + if ($null -eq $Manifest -or $null -eq $Manifest.hooks) { + return $count + } + + foreach ($eventProp in $Manifest.hooks.PSObject.Properties) { + foreach ($group in @($eventProp.Value)) { + $count += @($group.hooks).Count + } + } + + return $count +} + +function Remove-AgentOpsCodexHooks { + param($Manifest) + + $schema = $null + if ($null -ne $Manifest -and $Manifest.PSObject.Properties.Match('$schema').Count -gt 0) { + $schema = $Manifest.'$schema' + } + + $cleanedHooks = [ordered]@{} + if ($null -ne $Manifest -and $Manifest.PSObject.Properties.Match('hooks').Count -gt 0 -and $Manifest.hooks -isnot [System.Array]) { + foreach ($eventProp in $Manifest.hooks.PSObject.Properties) { + $keptGroups = New-Object System.Collections.ArrayList + foreach ($group in @($eventProp.Value)) { + $keptHandlers = @() + foreach ($handler in @($group.hooks)) { + if (-not (Test-AgentOpsCodexHookCommand ([string]$handler.command))) { + $keptHandlers += $handler + } + } + if ($keptHandlers.Count -gt 0) { + $newGroup = [ordered]@{} + if ($group.PSObject.Properties.Match('matcher').Count -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$group.matcher)) { + $newGroup['matcher'] = [string]$group.matcher + } + $newGroup['hooks'] = @($keptHandlers) + [void]$keptGroups.Add([pscustomobject]$newGroup) + } + } + if ($keptGroups.Count -gt 0) { + $cleanedHooks[$eventProp.Name] = @($keptGroups) + } + } + } + + $result = [ordered]@{} + if (-not [string]::IsNullOrWhiteSpace([string]$schema)) { + $result['$schema'] = $schema + } + $result['hooks'] = [pscustomobject]$cleanedHooks + return [pscustomobject]$result +} + +function Merge-CodexHooksManifest { + param($ExistingManifest, $NewManifest) + + $cleaned = Remove-AgentOpsCodexHooks $ExistingManifest + $mergedHooks = [ordered]@{} + + if ($null -ne $cleaned.hooks) { + foreach ($eventProp in $cleaned.hooks.PSObject.Properties) { + $mergedHooks[$eventProp.Name] = @($eventProp.Value) + } + } + if ($null -ne $NewManifest.hooks) { + foreach ($eventProp in $NewManifest.hooks.PSObject.Properties) { + $mergedHooks[$eventProp.Name] = @($eventProp.Value) + } + } + + $schema = $null + if ($NewManifest.PSObject.Properties.Match('$schema').Count -gt 0) { + $schema = $NewManifest.'$schema' + } elseif ($cleaned.PSObject.Properties.Match('$schema').Count -gt 0) { + $schema = $cleaned.'$schema' + } + + $result = [ordered]@{} + if (-not [string]::IsNullOrWhiteSpace([string]$schema)) { + $result['$schema'] = $schema + } + $result['hooks'] = [pscustomobject]$mergedHooks + return [pscustomobject]$result +} + function Install-CodexHooks { param([string]$RepoRoot, [string]$PluginCacheRoot, [string]$CodexHome) @@ -213,11 +343,15 @@ function Install-CodexHooks { $bashPluginRoot = Convert-ToBashPath -Path $PluginCacheRoot -BashFlavor $bashFlavor $rendered = Get-Content -LiteralPath $hooksManifest -Raw | ConvertFrom-Json - foreach ($hook in $rendered.hooks) { - if ($hook.command -match "/hooks/(.+\.sh)$") { - $hook.command = "bash `"$bashPluginRoot/hooks/$($matches[1])`"" - } else { - $hook.command = $hook.command.Replace('${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}', $bashPluginRoot) + foreach ($eventProp in $rendered.hooks.PSObject.Properties) { + foreach ($group in @($eventProp.Value)) { + foreach ($hook in @($group.hooks)) { + if ($hook.command -match "/hooks/(.+\.sh)$") { + $hook.command = "bash `"$bashPluginRoot/hooks/$($matches[1])`"" + } else { + $hook.command = $hook.command.Replace('${AGENTOPS_PLUGIN_ROOT:-~/.codex/plugins/cache/agentops}', $bashPluginRoot) + } + } } } @@ -225,13 +359,17 @@ function Install-CodexHooks { if (Test-Path -LiteralPath $hooksFile) { Copy-Item -LiteralPath $hooksFile -Destination "$hooksFile.bak.$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" -Force $existing = Get-Content -LiteralPath $hooksFile -Raw | ConvertFrom-Json - $keptHooks = @($existing.hooks | Where-Object { $_.name -notlike "agentops-*" }) - $rendered.hooks = @($keptHooks + @($rendered.hooks)) + if ($existing.hooks -is [System.Array]) { + Write-Warn "Existing ~/.codex/hooks.json uses the legacy flat-array shape; replacing it with the current Codex event-map schema." + } + $rendered = Merge-CodexHooksManifest $existing $rendered } New-Item -ItemType Directory -Path (Split-Path -Parent $hooksFile) -Force | Out-Null $rendered | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $hooksFile -Encoding utf8 - Write-Info "Codex hooks installed ($(@($rendered.hooks).Count) hooks)" + $handlerCount = Get-CodexHookHandlerCount $rendered + $eventCount = Get-CodexHookEventCount $rendered + Write-Info "Codex hooks installed ($handlerCount handlers across $eventCount events)" Write-Host " Hooks config: $hooksFile" Write-Host " Hook scripts: $hooksDst" } diff --git a/scripts/test-codex-native-install.sh b/scripts/test-codex-native-install.sh index 9f965fa31..0cb839e35 100755 --- a/scripts/test-codex-native-install.sh +++ b/scripts/test-codex-native-install.sh @@ -168,10 +168,12 @@ rg -q '^\[plugins\."agentops@agentops-marketplace"\]$' "$CODEX_HOME/config.toml" rg -q '^enabled = true$' "$CODEX_HOME/config.toml" || fail "config.toml missing enabled = true" rg -q '^codex_hooks = true$' "$CODEX_HOME/config.toml" || fail "config.toml missing codex_hooks = true" [[ -f "$CODEX_HOME/hooks.json" ]] || fail "Missing ~/.codex/hooks.json after native install" -jq -e '.hooks | length == 8' "$CODEX_HOME/hooks.json" >/dev/null \ - || fail "Expected 8 native Codex hooks in ~/.codex/hooks.json" -jq -e '.hooks[] | select(.name == "agentops-session-start")' "$CODEX_HOME/hooks.json" >/dev/null \ - || fail "Missing agentops-session-start in ~/.codex/hooks.json" +jq -e '.hooks | type == "object" and length == 5' "$CODEX_HOME/hooks.json" >/dev/null \ + || fail "Expected 5 native Codex hook events in ~/.codex/hooks.json" +jq -e '[.hooks | to_entries[] | .value[] | .hooks[]] | length == 8' "$CODEX_HOME/hooks.json" >/dev/null \ + || fail "Expected 8 native Codex hook handlers in ~/.codex/hooks.json" +jq -e '.hooks.SessionStart[]?.hooks[] | select(.command | test("session-start\\.sh$"))' "$CODEX_HOME/hooks.json" >/dev/null \ + || fail "Missing session-start.sh handler in ~/.codex/hooks.json" rg -q '"install_mode": "native-plugin"' "$CODEX_HOME/.agentops-codex-install.json" \ || fail "install metadata missing native-plugin mode" rg -q '"hook_runtime": "codex-native-hooks"' "$CODEX_HOME/.agentops-codex-install.json" \ @@ -181,6 +183,18 @@ rg -q '"hook_contract": "docs/contracts/hook-runtime-contract.md"' "$CODEX_HOME/ rg -q '"user_skills_root": null' "$CODEX_HOME/.agentops-codex-install.json" \ || fail "install metadata should not record a raw skills mirror" +info "Verifying --codex-home installs hooks into the target Codex home" +EXPLICIT_HOME_ROOT="/tmp/codex-native-plugin-explicit-${timestamp}" +EXPLICIT_CODEX_HOME="${EXPLICIT_HOME_ROOT}/explicit/.codex" +REAL_HOME_ROOT="${EXPLICIT_HOME_ROOT}/real-home" +HOME="$REAL_HOME_ROOT" bash "$INSTALL_SCRIPT" \ + --repo-root "$REPO_ROOT" \ + --codex-home "$EXPLICIT_CODEX_HOME" \ + --version "test-local" \ + --update-command "test-local" >/dev/null +[[ -f "$EXPLICIT_CODEX_HOME/hooks.json" ]] || fail "install-codex-plugin.sh did not create hooks.json under --codex-home" +[[ ! -f "$REAL_HOME_ROOT/.codex/hooks.json" ]] || fail "install-codex-plugin.sh leaked hooks.json into \$HOME instead of --codex-home" + info "Checking Codex entrypoint files for runtime-agnostic instructions" entrypoint_files=() while IFS= read -r -d '' file; do diff --git a/tests/skills/test-runtime-codex-smoke.sh b/tests/skills/test-runtime-codex-smoke.sh index eb19c3918..f3bf4cf60 100755 --- a/tests/skills/test-runtime-codex-smoke.sh +++ b/tests/skills/test-runtime-codex-smoke.sh @@ -55,15 +55,25 @@ echo "Stage 2: Codex native hooks" if [[ -f "$CODEX_HOOKS_JSON" ]]; then python3 -m json.tool "$CODEX_HOOKS_JSON" >/dev/null 2>&1 \ && pass "hooks/codex-hooks.json is valid JSON" || fail "hooks/codex-hooks.json is invalid JSON" - jq -e '.hooks | length >= 5' "$CODEX_HOOKS_JSON" >/dev/null 2>&1 \ - && pass "codex hook bundle defines multiple native hooks" || fail "codex hook bundle is unexpectedly small" - for hook_name in agentops-session-start agentops-inject agentops-flywheel-close; do - if jq -e --arg name "$hook_name" '.hooks[] | select(.name == $name)' "$CODEX_HOOKS_JSON" >/dev/null 2>&1; then - pass "codex hook bundle includes $hook_name" - else - fail "codex hook bundle missing $hook_name" - fi - done + jq -e '.hooks | type == "object" and length == 5' "$CODEX_HOOKS_JSON" >/dev/null 2>&1 \ + && pass "codex hook bundle defines 5 native hook events" || fail "codex hook bundle event map is unexpectedly small" + jq -e '[.hooks | to_entries[] | .value[] | .hooks[]] | length == 8' "$CODEX_HOOKS_JSON" >/dev/null 2>&1 \ + && pass "codex hook bundle defines 8 native hook handlers" || fail "codex hook bundle handler count drifted" + if jq -e '.hooks.SessionStart[]?.hooks[] | select(.command | test("session-start\\.sh$"))' "$CODEX_HOOKS_JSON" >/dev/null 2>&1; then + pass "codex hook bundle includes session-start.sh" + else + fail "codex hook bundle missing session-start.sh" + fi + if jq -e '.hooks.SessionStart[]?.hooks[] | select(.command | test("ao-inject\\.sh$"))' "$CODEX_HOOKS_JSON" >/dev/null 2>&1; then + pass "codex hook bundle includes ao-inject.sh" + else + fail "codex hook bundle missing ao-inject.sh" + fi + if jq -e '.hooks.Stop[]?.hooks[] | select(.command | test("ao-flywheel-close\\.sh$"))' "$CODEX_HOOKS_JSON" >/dev/null 2>&1; then + pass "codex hook bundle includes ao-flywheel-close.sh" + else + fail "codex hook bundle missing ao-flywheel-close.sh" + fi else fail "hooks/codex-hooks.json not found" fi @@ -129,8 +139,10 @@ fi if [[ -f "$CODEX_HOME/hooks.json" ]]; then pass "$CODEX_HOME/hooks.json created by installer" - jq -e '.hooks[] | select(.name == "agentops-session-start")' "$CODEX_HOME/hooks.json" >/dev/null 2>&1 \ - && pass "$CODEX_HOME/hooks.json includes agentops-session-start" || fail "$CODEX_HOME/hooks.json missing agentops-session-start" + jq -e '.hooks | type == "object" and length == 5' "$CODEX_HOME/hooks.json" >/dev/null 2>&1 \ + && pass "$CODEX_HOME/hooks.json uses the native event-map schema" || fail "$CODEX_HOME/hooks.json did not install the native event-map schema" + jq -e '.hooks.SessionStart[]?.hooks[] | select(.command | test("session-start\\.sh$"))' "$CODEX_HOME/hooks.json" >/dev/null 2>&1 \ + && pass "$CODEX_HOME/hooks.json includes session-start.sh" || fail "$CODEX_HOME/hooks.json missing session-start.sh" else fail "$CODEX_HOME/hooks.json missing after install" fi