Compare commits

...

5 Commits

Author SHA1 Message Date
Drew Ritter
36f3883f4e fix(codex): restore Superpowers after compaction
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 <noreply@anthropic.com>
2026-08-04 14:55:28 -07:00
Drew Ritter
05c2393b82 Merge pull request #2078 from obra/fix/x6a-sdd-batch-small-tasks
fix(sdd): batch small same-shape tasks into one dispatch
2026-08-04 14:26:31 -07:00
Drew Ritter
78cc189244 fix(sdd): batch reviews check the diff against the brief's file list
Batching moves N edits under one review, which changes the review's
failure profile: an implementer that silently skips one file of twelve
produces a diff full of correct, uniform edits — nothing conspicuous is
missing, and no seat in the pipeline was assigned to notice. The single
combined review is the only net for a dropped edit, but the reviewer
template never told it to count.

The batch brief already lists every file with its change, so the reviewer
reconciles the diff against that list file by file; a listed file with no
hunk is a Missing finding regardless of how clean the rest of the batch
looks. Conditional on a multi-file brief, so single-task reviews are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:25:40 -07:00
Drew Ritter
be76350536 Merge pull request #2080 from obra/fix/x7a-sdd-evidence-bearing-preflight
fix(sdd): preflight emits its checks as a ledger table and rules on what it surfaces
2026-08-04 14:22:57 -07:00
Jesse Vincent
e7a4285985 fix(sdd): batch small same-shape tasks into one dispatch
Plans sometimes enumerate many tiny, same-shape edits (one-line fixes,
constant changes, a field added across files) as separate tasks. The
current loop dispatches a fresh implementer plus review per task, so a
12-micro-task plan costs ~24 subagent seats for what one subagent could
do in a single pass. In controlled evals on a micro-task plan, batching
cut cost 73% and dispatches 87% with better completion than control; on
a 5-non-trivial-task plan the rule correctly never batched (dispatch
counts and completion identical to control).

Claude-Session: https://claude.ai/code/session_0185AJr98gHx5EmwqNeft4Sy
2026-08-02 19:36:55 -07:00
12 changed files with 278 additions and 23 deletions

View File

@@ -21,7 +21,7 @@
"workflow" "workflow"
], ],
"skills": "./skills/", "skills": "./skills/",
"hooks": {}, "hooks": "./hooks/hooks-codex.json",
"interface": { "interface": {
"displayName": "Superpowers", "displayName": "Superpowers",
"shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents", "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents",

View File

@@ -92,6 +92,22 @@ Superpowers is available via the [official Codex plugin marketplace](https://git
- Select `Install Plugin`. - 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 ### Cursor
- In Cursor Agent chat, install from marketplace: - In Cursor Agent chat, install from marketplace:

View File

@@ -237,10 +237,12 @@ nesting differ per harness**.
- Manifests: `.cursor-plugin/plugin.json` is the Shape A manifest example that - 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 points the harness at `./skills/` and the right `hooks-*.json`. Claude Code's
`.claude-plugin/plugin.json` sets neither field — it auto-discovers `skills/` `.claude-plugin/plugin.json` sets neither field — it auto-discovers `skills/`
and `hooks/hooks.json` by convention. Do **not** copy Codex's and `hooks/hooks.json` by convention. Codex's `.codex-plugin/plugin.json`
`.codex-plugin/plugin.json` for Shape A: it declares an empty `hooks` object points `hooks` at `./hooks/hooks-codex.json` — a compaction-only hook, not a
specifically to suppress Codex's `hooks/hooks.json` auto-discovery, because bootstrap injector: Codex surfaces skills natively at session start, so its
Codex surfaces skills natively and runs no session-start hook. 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 > **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 > `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 | | 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 | | 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 Codecompatible tool surface) | `tests/hooks/` | hand-authored | | Cursor | `.cursor-plugin/plugin.json` + `hooks/hooks-cursor.json` | shell hook → `hooks/session-start` (`additional_context`) | none needed (Claude Codecompatible 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 Codecompatible tool surface) | `tests/hooks/` | — | | Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | none needed (Claude Codecompatible tool surface) | `tests/hooks/` | — |
| Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` | | Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` |

17
hooks/hooks-codex.json Normal file
View File

@@ -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
}
]
}
]
}
}

56
hooks/session-start-codex Executable file
View File

@@ -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' "<EXTREMELY_IMPORTANT>"
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' "</EXTREMELY_IMPORTANT>"
printf '%s\n' "<CONTEXT_RESTORED>"
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' "</CONTEXT_RESTORED>"
exit 0

View File

@@ -40,8 +40,9 @@ Options:
-h, --help Show this help. -h, --help Show this help.
The archive is rootless: .codex-plugin/, assets/, skills/, README.md, LICENSE, 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, CODE_OF_CONDUCT.md, and the Codex SessionStart hook (hooks/hooks-codex.json plus
docs, and other harness manifests are intentionally not shipped. 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 EOF
} }
@@ -238,6 +239,9 @@ git -C "$REPO_ROOT" -c tar.umask=0022 archive --format=tar "$REF" -- \
LICENSE \ LICENSE \
README.md \ README.md \
assets \ assets \
hooks/hooks-codex.json \
hooks/run-hook.cmd \
hooks/session-start-codex \
skills \ skills \
| tar -xpf - -C "$STAGE" | tar -xpf - -C "$STAGE"
@@ -333,7 +337,7 @@ esac
unexpected_paths="$( unexpected_paths="$(
printf '%s\n' "$archive_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 if [[ -n "$unexpected_paths" ]]; then
printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2 printf '%s\n' "$unexpected_paths" | sed 's/^/ /' >&2

View File

@@ -217,6 +217,14 @@ that implementer. Single-file mechanical fixes also take the cheapest tier.
## The Task Loop ## The Task Loop
**Batch small same-shape work.** When the plan lists several tasks that are
each a small, independent edit of the same kind — the same one-line fix,
constant change, or field addition repeated across files — do not dispatch
one subagent per task. Compose ONE dispatch brief listing every file and
its change, send the whole batch to a single subagent, and review its diff
as one unit. Reserve one-dispatch-per-task for work that needs its own
judgment, its own tests, or its own review surface.
Everything you paste into a dispatch prompt — and everything a subagent Everything you paste into a dispatch prompt — and everything a subagent
prints back — stays resident in your context for the rest of the session prints back — stays resident in your context for the rest of the session
and is re-read on every later turn. Hand artifacts over as files. and is re-read on every later turn. Hand artifacts over as files.

View File

@@ -95,6 +95,12 @@ Subagent (general-purpose):
- **Misunderstood:** right feature built the wrong way, wrong problem - **Misunderstood:** right feature built the wrong way, wrong problem
solved solved
If the brief lists several files each with its own change (a batched
dispatch), check the diff against that list file by file: every listed
file must have its corresponding hunk. A listed file the diff never
touches is a Missing finding, no matter how clean the rest of the
batch looks.
If a requirement cannot be verified from this diff alone (it lives in If a requirement cannot be verified from this diff alone (it lives in
unchanged code or spans tasks), report it as a ⚠️ item instead of unchanged code or spans tasks), report it as a ⚠️ item instead of
broadening your search. broadening your search.

View File

@@ -78,6 +78,22 @@ default_subagent_model = "<a mid-tier model from your spawn allowlist>"
default_subagent_reasoning_effort = "medium" 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
`<CONTEXT_RESTORED>` 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 ## Environment Detection
Skills that create worktrees or finish branches should detect their Skills that create worktrees or finish branches should detect their

View File

@@ -52,24 +52,36 @@ if not plugin_manifest.exists():
manifest = json.loads(plugin_manifest.read_text(encoding="utf-8")) manifest = json.loads(plugin_manifest.read_text(encoding="utf-8"))
assert_equal(manifest.get("name"), plugin.get("name"), "plugin manifest name") assert_equal(manifest.get("name"), plugin.get("name"), "plugin manifest name")
# Codex auto-discovers a plugin's hooks/hooks.json whenever the Codex manifest # The Codex manifest must declare its hooks explicitly. An absent field makes
# has no `hooks` field: load_plugin_hooks falls back to a hardcoded # load_plugin_hooks fall back to a hardcoded DEFAULT_HOOKS_CONFIG_FILE =
# DEFAULT_HOOKS_CONFIG_FILE = "hooks/hooks.json" and registers it. That file is # "hooks/hooks.json" — the Claude Code SessionStart hook, which injects the
# the Claude Code SessionStart hook, it is tracked in this repo, and this # bootstrap at startup and must not run on Codex. The explicit pointer both
# marketplace installs the whole repo root (source url "./"), so on Codex the # registers the Codex compaction re-injection hook and overrides that fallback.
# 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.
hooks_config = repo_root / "hooks" / "hooks.json" hooks_config = repo_root / "hooks" / "hooks.json"
if not hooks_config.exists(): if not hooks_config.exists():
raise AssertionError("hooks/hooks.json must exist (Claude Code SessionStart hook)") raise AssertionError("hooks/hooks.json must exist (Claude Code SessionStart hook)")
assert_equal( assert_equal(
manifest.get("hooks"), manifest.get("hooks"),
{}, "./hooks/hooks-codex.json",
"Codex manifest must declare empty hooks {} to suppress hooks/hooks.json auto-discovery", "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") print("Codex marketplace manifest looks good")

View File

@@ -141,7 +141,7 @@ tar_extracted="$TEST_ROOT/tar-extracted"
write_metadata_fixture "$metadata_source" write_metadata_fixture "$metadata_source"
source_hooks="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json")).get("hooks"))')" 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 if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --output "$archive" 2>&1)"; then
pass "package script exits successfully" pass "package script exits successfully"
@@ -163,10 +163,13 @@ assert_contains "$output" "SHA-256:" "reports archive checksum"
extract_archive "$archive" "$extracted" extract_archive "$archive" "$extracted"
archive_paths="$(list_archive "$archive" | normalize_archive_paths)" 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_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" ".codex-plugin/plugin.json" "archive includes Codex manifest"
assert_contains "$archive_paths" "skills/brainstorming/SKILL.md" "archive includes skills" 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" "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/app-icon.png" "archive includes app icon"
assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon" assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon"

View File

@@ -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 <stdin-payload> — 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 \
"<EXTREMELY_IMPORTANT>" \
"You have superpowers." \
"name: using-superpowers" \
"<CONTEXT_RESTORED>" \
"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" == *"<CONTEXT_RESTORED>"* ]]; 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"