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"