From 6b1589c1e530a2e153ad7ed9fc48b0d0df3fba22 Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Thu, 23 Jul 2026 12:21:49 -0700 Subject: [PATCH] feat(codex): re-inject the bootstrap after context compaction via SessionStart hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 0.145 re-fires SessionStart with source:"compact" after every context compaction, and injects hook stdout into the live model context (both verified with sentinel probes on codex-cli 0.145.0). Compaction replaces the transcript with a summary that sheds the using-superpowers bootstrap and the active skill's instructions; in instrumented SDD runs the first post-compaction dispatch that lacked a freshly printed reminder reverted to harness defaults (fork_turns=all, inherited frontier model) and the drift then self-perpetuated. Claude Code never exhibits this because its SessionStart matcher (startup|clear|compact) re-injects the bootstrap at the same moment — this hook restores that parity on Codex. On source:"startup" the hook emits nothing: the native plugin path owns session-start injection, and duplicating it would recreate the redundancy that led to the original session-start-codex removal. Output is plain text (Codex consumes raw stdout, unlike the JSON envelopes hooks/session-start emits for other harnesses), and every failure path is fail-open: bad stdin, missing skill file, or any error yields empty output and exit 0 so a hook problem can never break a session. Ships with hooks-codex.json.example for the user-level ~/.codex/hooks.json merge and tests covering source filtering, whitespace-tolerant matching, decoy fields, and fail-open behavior. Co-Authored-By: Claude Fable 5 --- hooks/hooks-codex.json.example | 15 ++++ hooks/session-start-codex | 56 ++++++++++++ tests/hooks/test-session-start-codex.sh | 110 ++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 hooks/hooks-codex.json.example create mode 100755 hooks/session-start-codex create mode 100755 tests/hooks/test-session-start-codex.sh diff --git a/hooks/hooks-codex.json.example b/hooks/hooks-codex.json.example new file mode 100644 index 00000000..f621cdb8 --- /dev/null +++ b/hooks/hooks-codex.json.example @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/ABSOLUTE/PATH/TO/superpowers/hooks/session-start-codex\"", + "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/tests/hooks/test-session-start-codex.sh b/tests/hooks/test-session-start-codex.sh new file mode 100755 index 00000000..002d1d75 --- /dev/null +++ b/tests/hooks/test-session-start-codex.sh @@ -0,0 +1,110 @@ +#!/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" +EXAMPLE_UNDER_TEST="$REPO_ROOT/hooks/hooks-codex.json.example" + +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 example = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); +const entry = example.hooks.SessionStart[0].hooks[0]; +if (entry.type !== "command") { + console.error(`example 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}`); + process.exit(1); +} +' "$EXAMPLE_UNDER_TEST"; then + pass "hooks-codex.json.example parses and invokes session-start-codex" +else + fail "hooks-codex.json.example parses and invokes session-start-codex" +fi + +if [[ "$FAILURES" -gt 0 ]]; then + echo "STATUS: FAILED ($FAILURES failure(s))" + exit 1 +fi + +echo "STATUS: PASSED"