diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a6b31431..0fdc8204 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -21,7 +21,7 @@ "workflow" ], "skills": "./skills/", - "hooks": {}, + "hooks": "./hooks/hooks-codex.json", "interface": { "displayName": "Superpowers", "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents", diff --git a/README.md b/README.md index bd97c2c5..00c37b01 100644 --- a/README.md +++ b/README.md @@ -98,37 +98,18 @@ Superpowers is available via the [official Codex plugin marketplace](https://git - Select `Install Plugin`. -#### Codex: compaction re-injection hook (recommended) +#### Codex: compaction re-injection hook Codex compacts long sessions, replacing the transcript with a summary that drops Superpowers' skill instructions mid-run — long autonomous workflows (like subagent-driven-development) then drift back to harness defaults. -Claude Code re-injects the bootstrap after every compaction; this hook -restores the same behavior on Codex (0.145+). +Claude Code re-injects the bootstrap after every compaction; the plugin ships +a SessionStart hook (`hooks/hooks-codex.json`) that restores the same +behavior on Codex (0.145+). It fires only on post-compaction re-starts +(`source: "compact"`) and is silent at normal session start. -Merge `hooks/hooks-codex.json.example` from your Superpowers install into -`~/.codex/hooks.json`, replacing the placeholder with the absolute path to -your install: - -```json -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"/path/to/superpowers/hooks/session-start-codex\"", - "timeout": 30 - } - ] - } - ] - } -} -``` - -Codex asks you to trust the hook once, the first time it runs in the app. +The hook installs with the plugin — no configuration needed. Codex asks you +to review and trust it once, the first time it loads after install or update. Headless automation (CI, eval harnesses) must pass `--dangerously-bypass-hook-trust` instead, because untrusted hooks are skipped silently. diff --git a/docs/porting-to-a-new-harness.md b/docs/porting-to-a-new-harness.md index 4ae9603d..c8880291 100644 --- a/docs/porting-to-a-new-harness.md +++ b/docs/porting-to-a-new-harness.md @@ -237,10 +237,12 @@ nesting differ per harness**. - Manifests: `.cursor-plugin/plugin.json` is the Shape A manifest example that points the harness at `./skills/` and the right `hooks-*.json`. Claude Code's `.claude-plugin/plugin.json` sets neither field — it auto-discovers `skills/` - and `hooks/hooks.json` by convention. Do **not** copy Codex's - `.codex-plugin/plugin.json` for Shape A: it declares an empty `hooks` object - specifically to suppress Codex's `hooks/hooks.json` auto-discovery, because - Codex surfaces skills natively and runs no session-start hook. + and `hooks/hooks.json` by convention. Codex's `.codex-plugin/plugin.json` + points `hooks` at `./hooks/hooks-codex.json` — a compaction-only hook, not a + bootstrap injector: Codex surfaces skills natively at session start, so its + hook fires only on post-compaction re-starts. The explicit pointer also + suppresses Codex's `hooks/hooks.json` auto-discovery fallback, which would + otherwise run the Claude Code hook. > **A hook *system* is not a session-start *event*.** A harness can have a > `hooks.json` mechanism — and even contain the literal string `SessionStart` in @@ -785,7 +787,7 @@ Use this as the live index; when in doubt, read the files, not this table. | Harness | Entry point | Bootstrap mechanism | Tool mapping | Tests | Distribution | |---|---|---|---|---|---| | Claude Code | `.claude-plugin/plugin.json` + `hooks/hooks.json` | shell hook → `hooks/session-start` (`hookSpecificOutput.additionalContext`) | native `Skill` tool; no adapter file needed | `tests/hooks/` | marketplace | -| Codex | `.codex-plugin/plugin.json` (declares empty `hooks`) | native skill discovery (no session-start hook) | `references/codex-tools.md` | `tests/codex/`, `tests/codex-plugin-sync/` | fork sync (`scripts/sync-to-codex-plugin.sh`) | +| Codex | `.codex-plugin/plugin.json` + `hooks/hooks-codex.json` | native skill discovery at startup; shell hook → `hooks/session-start-codex` re-injects after compaction only | `references/codex-tools.md` | `tests/codex/`, `tests/codex-plugin-sync/` | fork sync (`scripts/sync-to-codex-plugin.sh`) | | Cursor | `.cursor-plugin/plugin.json` + `hooks/hooks-cursor.json` | shell hook → `hooks/session-start` (`additional_context`) | none needed (Claude Code–compatible tool surface) | `tests/hooks/` | hand-authored | | Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | none needed (Claude Code–compatible tool surface) | `tests/hooks/` | — | | Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` | diff --git a/hooks/hooks-codex.json.example b/hooks/hooks-codex.json similarity index 55% rename from hooks/hooks-codex.json.example rename to hooks/hooks-codex.json index f621cdb8..d89d69c6 100644 --- a/hooks/hooks-codex.json.example +++ b/hooks/hooks-codex.json @@ -2,10 +2,12 @@ "hooks": { "SessionStart": [ { + "matcher": "compact", "hooks": [ { "type": "command", - "command": "bash \"/ABSOLUTE/PATH/TO/superpowers/hooks/session-start-codex\"", + "command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex", + "async": false, "timeout": 30 } ] diff --git a/scripts/package-codex-plugin.sh b/scripts/package-codex-plugin.sh index 5308e28d..d2dd7831 100755 --- a/scripts/package-codex-plugin.sh +++ b/scripts/package-codex-plugin.sh @@ -40,8 +40,9 @@ Options: -h, --help Show this help. The archive is rootless: .codex-plugin/, assets/, skills/, README.md, LICENSE, -and CODE_OF_CONDUCT.md sit at the archive root. Source-only repo files, hooks, tests, -docs, and other harness manifests are intentionally not shipped. +CODE_OF_CONDUCT.md, and the Codex SessionStart hook (hooks/hooks-codex.json plus +its two scripts) sit at the archive root. Source-only repo files, other-harness +hooks, tests, docs, and other harness manifests are intentionally not shipped. EOF } @@ -238,6 +239,9 @@ git -C "$REPO_ROOT" -c tar.umask=0022 archive --format=tar "$REF" -- \ LICENSE \ README.md \ assets \ + hooks/hooks-codex.json \ + hooks/run-hook.cmd \ + hooks/session-start-codex \ skills \ | tar -xpf - -C "$STAGE" @@ -333,7 +337,7 @@ esac unexpected_paths="$( printf '%s\n' "$archive_paths" | - grep -E '(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' || true + grep -E '(^superpowers/|^\.agents/|^hooks/hooks\.json$|^hooks/hooks-cursor\.json$|^hooks/session-start$|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' || true )" if [[ -n "$unexpected_paths" ]]; then printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2 diff --git a/skills/using-superpowers/references/codex-tools.md b/skills/using-superpowers/references/codex-tools.md index 7b4e0a25..1afb3f56 100644 --- a/skills/using-superpowers/references/codex-tools.md +++ b/skills/using-superpowers/references/codex-tools.md @@ -42,12 +42,14 @@ Context compaction replaces your transcript with a summary that keeps your progress but not your working instructions — the first post-compaction dispatch is where routing drift starts, and once one bare spawn lands, the broken pattern becomes its own precedent. The -compaction re-injection hook (README, "Codex: compaction re-injection -hook") restores the bootstrap after every compaction; recommend it to -your human partner if it is not installed. Without it, the printed -`dispatch:` hints are your only re-grounding — treat every one you see -as authoritative, especially right after a summary appears in your -context. +plugin ships a compaction re-injection hook (`hooks/hooks-codex.json`, +Codex 0.145+) that restores the bootstrap after every compaction; it +needs one-time trust approval, so if you never see a +`` block after a compaction, tell your human partner +the hook may be untrusted or unsupported on this version. Without it, +the printed `dispatch:` hints are your only re-grounding — treat every +one you see as authoritative, especially right after a summary appears +in your context. ## Environment Detection diff --git a/tests/codex/test-marketplace-manifest.sh b/tests/codex/test-marketplace-manifest.sh index 4301a06e..3bdde0b7 100755 --- a/tests/codex/test-marketplace-manifest.sh +++ b/tests/codex/test-marketplace-manifest.sh @@ -52,25 +52,37 @@ if not plugin_manifest.exists(): manifest = json.loads(plugin_manifest.read_text(encoding="utf-8")) assert_equal(manifest.get("name"), plugin.get("name"), "plugin manifest name") -# Codex auto-discovers a plugin's hooks/hooks.json whenever the Codex manifest -# has no `hooks` field: load_plugin_hooks falls back to a hardcoded -# DEFAULT_HOOKS_CONFIG_FILE = "hooks/hooks.json" and registers it. That file is -# the Claude Code SessionStart hook, it is tracked in this repo, and this -# marketplace installs the whole repo root (source url "./"), so on Codex the -# fallback re-registers the SessionStart hook and its install-time trust prompt. -# Declaring an empty inline hooks object ({}) parses as an empty inline hook set -# and suppresses the auto-discovery. An absent field, an empty array ([]), and -# an empty inline list all collapse back to the fallback, so the value must be -# exactly an empty object. +# The Codex manifest must declare its hooks explicitly. An absent field makes +# load_plugin_hooks fall back to a hardcoded DEFAULT_HOOKS_CONFIG_FILE = +# "hooks/hooks.json" — the Claude Code SessionStart hook, which injects the +# bootstrap at startup and must not run on Codex. The explicit pointer both +# registers the Codex compaction re-injection hook and overrides that fallback. hooks_config = repo_root / "hooks" / "hooks.json" if not hooks_config.exists(): raise AssertionError("hooks/hooks.json must exist (Claude Code SessionStart hook)") assert_equal( manifest.get("hooks"), - {}, - "Codex manifest must declare empty hooks {} to suppress hooks/hooks.json auto-discovery", + "./hooks/hooks-codex.json", + "Codex manifest must point hooks at the Codex hook config (an absent field " + "falls back to auto-discovering the Claude Code hooks/hooks.json)", ) +codex_hooks_path = repo_root / "hooks" / "hooks-codex.json" +if not codex_hooks_path.exists(): + raise AssertionError("hooks/hooks-codex.json must exist (Codex manifest points at it)") + +codex_hooks = json.loads(codex_hooks_path.read_text(encoding="utf-8")) +session_start = codex_hooks["hooks"]["SessionStart"] +assert_equal(len(session_start), 1, "Codex SessionStart hook group count") +assert_equal(session_start[0].get("matcher"), "compact", "Codex hook matcher") +entry = session_start[0]["hooks"][0] +assert_equal(entry.get("type"), "command", "Codex hook type") +command = entry.get("command", "") +if "${PLUGIN_ROOT}" not in command or not command.endswith("session-start-codex"): + raise AssertionError( + f"Codex hook command must run session-start-codex via ${{PLUGIN_ROOT}}: {command!r}" + ) + print("Codex marketplace manifest looks good") PY diff --git a/tests/codex/test-package-codex-plugin.sh b/tests/codex/test-package-codex-plugin.sh index 947cb6db..b782bf44 100755 --- a/tests/codex/test-package-codex-plugin.sh +++ b/tests/codex/test-package-codex-plugin.sh @@ -141,7 +141,7 @@ tar_extracted="$TEST_ROOT/tar-extracted" write_metadata_fixture "$metadata_source" source_hooks="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json")).get("hooks"))')" -assert_equals "$source_hooks" "{}" "source Codex manifest suppresses local hook auto-discovery" +assert_equals "$source_hooks" "./hooks/hooks-codex.json" "source Codex manifest declares the Codex hook config" if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --output "$archive" 2>&1)"; then pass "package script exits successfully" @@ -163,10 +163,13 @@ assert_contains "$output" "SHA-256:" "reports archive checksum" extract_archive "$archive" "$extracted" archive_paths="$(list_archive "$archive" | normalize_archive_paths)" -unexpected_pattern='(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' +unexpected_pattern='(^superpowers/|^\.agents/|^hooks/hooks\.json$|^hooks/hooks-cursor\.json$|^hooks/session-start$|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)' assert_not_matches "$archive_paths" "$unexpected_pattern" "archive excludes source-only paths" assert_contains "$archive_paths" ".codex-plugin/plugin.json" "archive includes Codex manifest" assert_contains "$archive_paths" "skills/brainstorming/SKILL.md" "archive includes skills" +assert_contains "$archive_paths" "hooks/hooks-codex.json" "archive includes Codex hook config" +assert_contains "$archive_paths" "hooks/session-start-codex" "archive includes Codex hook script" +assert_contains "$archive_paths" "hooks/run-hook.cmd" "archive includes hook runner" assert_contains "$archive_paths" "skills/brainstorming/agents/openai.yaml" "archive includes OpenAI skill metadata" assert_contains "$archive_paths" "assets/app-icon.png" "archive includes app icon" assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon" diff --git a/tests/hooks/test-session-start-codex.sh b/tests/hooks/test-session-start-codex.sh index 002d1d75..713fa28b 100755 --- a/tests/hooks/test-session-start-codex.sh +++ b/tests/hooks/test-session-start-codex.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start-codex" -EXAMPLE_UNDER_TEST="$REPO_ROOT/hooks/hooks-codex.json.example" +CONFIG_UNDER_TEST="$REPO_ROOT/hooks/hooks-codex.json" FAILURES=0 @@ -86,20 +86,25 @@ else fi if node -e ' -const example = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); -const entry = example.hooks.SessionStart[0].hooks[0]; +const config = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); +const group = config.hooks.SessionStart[0]; +if (group.matcher !== "compact") { + console.error(`hook matcher is ${JSON.stringify(group.matcher)}, expected "compact"`); + process.exit(1); +} +const entry = group.hooks[0]; if (entry.type !== "command") { - console.error(`example hook type is ${JSON.stringify(entry.type)}, expected "command"`); + console.error(`hook type is ${JSON.stringify(entry.type)}, expected "command"`); process.exit(1); } -if (!/session-start-codex"$/.test(entry.command)) { - console.error(`unexpected example command shape: ${entry.command}`); +if (!entry.command.includes("${PLUGIN_ROOT}") || !/run-hook\.cmd" session-start-codex$/.test(entry.command)) { + console.error(`unexpected command shape: ${entry.command}`); process.exit(1); } -' "$EXAMPLE_UNDER_TEST"; then - pass "hooks-codex.json.example parses and invokes session-start-codex" +' "$CONFIG_UNDER_TEST"; then + pass "hooks-codex.json runs session-start-codex via \${PLUGIN_ROOT} on compact" else - fail "hooks-codex.json.example parses and invokes session-start-codex" + fail "hooks-codex.json runs session-start-codex via \${PLUGIN_ROOT} on compact" fi if [[ "$FAILURES" -gt 0 ]]; then