From 36f3883f4ef1b3ca70307fd05509c9a501d772a3 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Tue, 4 Aug 2026 14:55:28 -0700 Subject: [PATCH] fix(codex): restore Superpowers after compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-fires SessionStart with source "compact" after every context compaction; the summary keeps progress but sheds the bootstrap and any active skill's instructions — measured July cause of post-compaction dispatch drift in long SDD runs (with re-injection: 18/18 hook fires, 66/66 post-compaction dispatch tuples correct in a 12.4h stress run). Codex discovers skills natively at startup, so the hook is silent there: the compact re-fire is the one unowned lifecycle point. Adds the plugin-provided hook (hooks-codex.json + session-start-codex, compact-only, fails open), wires it into the manifest and package, documents install/trust behavior, updates codex-tools.md with the re-grounding fallback, and tests the hook lifecycle, manifest, and archive contents. Rebuilt from the July codex-spinout-fixes branch, pared to the hook core: the dispatch-hints layer it used to ride with is superseded by the merged 2059-2062/2077-2080 stack and is dropped. Co-Authored-By: Claude Fable 5 --- .codex-plugin/plugin.json | 2 +- README.md | 16 +++ docs/porting-to-a-new-harness.md | 12 +- hooks/hooks-codex.json | 17 +++ hooks/session-start-codex | 56 +++++++++ scripts/package-codex-plugin.sh | 10 +- .../references/codex-tools.md | 16 +++ tests/codex/test-marketplace-manifest.sh | 36 ++++-- tests/codex/test-package-codex-plugin.sh | 7 +- tests/hooks/test-session-start-codex.sh | 115 ++++++++++++++++++ 10 files changed, 264 insertions(+), 23 deletions(-) create mode 100644 hooks/hooks-codex.json create mode 100755 hooks/session-start-codex create mode 100755 tests/hooks/test-session-start-codex.sh diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c777f316..fe7a45f4 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 2757b2ef..a87f10ec 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,22 @@ Superpowers is available via the [official Codex plugin marketplace](https://git - Select `Install Plugin`. +#### 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; 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. + +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. + ### Cursor - In Cursor Agent chat, install from marketplace: 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 b/hooks/hooks-codex.json new file mode 100644 index 00000000..d89d69c6 --- /dev/null +++ b/hooks/hooks-codex.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex", + "async": false, + "timeout": 30 + } + ] + } + ] + } +} diff --git a/hooks/session-start-codex b/hooks/session-start-codex new file mode 100755 index 00000000..c9e5a6ce --- /dev/null +++ b/hooks/session-start-codex @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Codex SessionStart hook for the superpowers plugin. +# +# Codex re-fires SessionStart with source:"compact" after every context +# compaction (verified on codex-cli 0.145.0). Compaction replaces the live +# context with a summary, which sheds the using-superpowers bootstrap and any +# active skill's instructions — the measured cause of mid-session dispatch +# drift in long multi-agent runs. This hook re-injects the bootstrap at +# exactly that moment, restoring the same re-injection Claude Code performs +# via its "startup|clear|compact" SessionStart matcher. +# +# On source:"startup" it emits nothing: the native Codex plugin path owns +# session-start injection, and duplicating it here would recreate the +# redundancy that led to the original session-start-codex hook's removal. +# +# Codex injects raw hook stdout into the model's context (verified with +# sentinel probes), so output is plain text — not the JSON envelopes other +# harnesses require of hooks/session-start. +# +# A hook failure must never break a session: every path fails open to empty +# output and exit 0. + +set -u + +payload="$(cat 2>/dev/null || true)" + +# Act only on post-compaction re-fires. Tolerate arbitrary whitespace around +# the JSON colon; anything unparseable falls through to a silent no-op. +if ! printf '%s' "$payload" | grep -qE '"source"[[:space:]]*:[[:space:]]*"compact"'; then + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +using_superpowers_content="$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>/dev/null)" || using_superpowers_content="" +if [ -z "$using_superpowers_content" ]; then + exit 0 +fi + +# printf instead of heredocs throughout: heredocs hang on bash 5.3+. +# See: https://github.com/obra/superpowers/issues/571 +printf '%s\n' "" +printf '%s\n\n' "You have superpowers." +printf '%s\n\n' "**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**" +printf '%s\n' "$using_superpowers_content" +printf '%s\n\n' "" +printf '%s\n' "" +printf '%s\n' "Your context was just summarized (compacted). The summary preserves your progress but not your working instructions — the files are authoritative." +printf '%s\n' "" +printf '%s\n' "Before your next tool call:" +printf '%s\n' "- Re-read the SKILL.md of any skill you are mid-way through executing. If you are executing subagent-driven-development, re-read skills/subagent-driven-development/SKILL.md." +printf '%s\n' "- On Codex, also re-read skills/using-superpowers/references/codex-tools.md and follow its dispatch rules on every spawn_agent call." +printf '%s\n' "" + +exit 0 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 e4488fb2..f6220394 100644 --- a/skills/using-superpowers/references/codex-tools.md +++ b/skills/using-superpowers/references/codex-tools.md @@ -78,6 +78,22 @@ default_subagent_model = "" default_subagent_reasoning_effort = "medium" ``` +## Compaction sheds these instructions + +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 +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, +re-ground yourself: when a summary appears in your context, re-read +this file and the SKILL.md of the skill you are mid-way through +executing before your next dispatch, and trust the ledger over your +summarized memory of what happened. + ## Environment Detection Skills that create worktrees or finish branches should detect their 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 new file mode 100755 index 00000000..713fa28b --- /dev/null +++ b/tests/hooks/test-session-start-codex.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start-codex" +CONFIG_UNDER_TEST="$REPO_ROOT/hooks/hooks-codex.json" + +FAILURES=0 + +pass() { + echo " [PASS] $1" +} + +fail() { + echo " [FAIL] $1" + FAILURES=$((FAILURES + 1)) +} + +# run_hook — echoes hook stdout; fails the calling test on +# non-zero exit. env -i mirrors the codex hook executor's clean environment. +run_hook() { + printf '%s' "$1" | env -i PATH="${PATH:-}" bash "$HOOK_UNDER_TEST" +} + +echo "Codex SessionStart hook tests" + +startup_payload='{"session_id":"s","hook_event_name":"SessionStart","model":"gpt-5.6-terra","source":"startup"}' +if output="$(run_hook "$startup_payload")" && [ -z "$output" ]; then + pass "source=startup emits nothing and exits 0" +else + fail "source=startup emits nothing and exits 0" + printf '%s\n' "$output" | head -3 | sed 's/^/ /' +fi + +compact_payload='{"session_id":"s","hook_event_name":"SessionStart","model":"gpt-5.6-terra","source":"compact"}' +if output="$(run_hook "$compact_payload")"; then + ok=1 + for needle in \ + "" \ + "You have superpowers." \ + "name: using-superpowers" \ + "" \ + "subagent-driven-development/SKILL.md" \ + "references/codex-tools.md"; do + if [[ "$output" != *"$needle"* ]]; then + ok=0 + echo " missing: $needle" + fi + done + if [ "$ok" -eq 1 ]; then + pass "source=compact emits bootstrap plus re-read addendum" + else + fail "source=compact emits bootstrap plus re-read addendum" + fi +else + fail "source=compact emits bootstrap plus re-read addendum (hook exited non-zero)" +fi + +# Whitespace-tolerant source matching (serializers vary). +spaced_payload='{"hook_event_name":"SessionStart", "source" : "compact"}' +if output="$(run_hook "$spaced_payload")" && [[ "$output" == *""* ]]; then + pass "whitespace around the source key still triggers injection" +else + fail "whitespace around the source key still triggers injection" +fi + +if output="$(printf '' | env -i PATH="${PATH:-}" bash "$HOOK_UNDER_TEST")" && [ -z "$output" ]; then + pass "empty stdin fails open to no output, exit 0" +else + fail "empty stdin fails open to no output, exit 0" +fi + +if output="$(run_hook 'not json at all {{{')" && [ -z "$output" ]; then + pass "garbage stdin fails open to no output, exit 0" +else + fail "garbage stdin fails open to no output, exit 0" +fi + +# A compact mention inside some other field must not trigger injection. +decoy_payload='{"hook_event_name":"SessionStart","source":"startup","cwd":"/tmp/compact"}' +if output="$(run_hook "$decoy_payload")" && [ -z "$output" ]; then + pass "compact appearing outside the source field does not trigger" +else + fail "compact appearing outside the source field does not trigger" +fi + +if node -e ' +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(`hook type is ${JSON.stringify(entry.type)}, expected "command"`); + process.exit(1); +} +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); +} +' "$CONFIG_UNDER_TEST"; then + pass "hooks-codex.json runs session-start-codex via \${PLUGIN_ROOT} on compact" +else + fail "hooks-codex.json runs session-start-codex via \${PLUGIN_ROOT} on compact" +fi + +if [[ "$FAILURES" -gt 0 ]]; then + echo "STATUS: FAILED ($FAILURES failure(s))" + exit 1 +fi + +echo "STATUS: PASSED"