Compare commits

..

1 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
14 changed files with 292 additions and 125 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

@@ -2,31 +2,6 @@
Superpowers is a complete software development methodology for your coding agents, built on top of a set of composable skills and some initial instructions that make sure your agent uses them. Superpowers is a complete software development methodology for your coding agents, built on top of a set of composable skills and some initial instructions that make sure your agent uses them.
## Table of Contents
- [Quickstart](#quickstart)
- [How it works](#how-it-works)
- [Commercial Services](#commercial-services)
- [Installation](#installation)
- [Claude Code](#claude-code)
- [Antigravity](#antigravity)
- [Codex App](#codex-app)
- [Codex CLI](#codex-cli)
- [Cursor](#cursor)
- [Factory Droid](#factory-droid)
- [Gemini CLI](#gemini-cli)
- [GitHub Copilot CLI](#github-copilot-cli)
- [Kimi Code](#kimi-code)
- [OpenCode](#opencode)
- [Pi](#pi)
- [The Basic Workflow](#the-basic-workflow)
- [Community](#community)
- [What's Inside](#whats-inside)
- [Philosophy](#philosophy)
- [Contributing](#contributing)
- [Updating](#updating)
- [License](#license)
- [Visual companion telemetry](#visual-companion-telemetry)
## Quickstart ## Quickstart
@@ -117,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:
@@ -236,14 +227,6 @@ The Pi package loads the Superpowers skills and a small extension that injects t
**The agent checks for relevant skills before any task.** Mandatory workflows, not suggestions. **The agent checks for relevant skills before any task.** Mandatory workflows, not suggestions.
## Community
Superpowers is built by [Jesse Vincent](https://blog.fsck.com) and the rest of the folks at [Prime Radiant](https://primeradiant.com).
- **Discord**: [Join us](https://discord.gg/35wsABTejz) for community support, questions, and sharing what you're building with Superpowers
- **Issues**: https://github.com/obra/superpowers/issues
- **Release announcements**: [Sign up](https://primeradiant.com/superpowers/) to get notified about new versions
## What's Inside ## What's Inside
### Skills Library ### Skills Library
@@ -304,3 +287,11 @@ MIT License - see LICENSE file for details
## Visual companion telemetry ## Visual companion telemetry
Because skills and plugins don't provide any feedback to creators, we have no idea how many of you are using Superpowers. By default, the Prime Radiant logo on brainstorming's optional visual companion feature is loaded from our website. It includes the version of Superpowers in use. It does not include any details about your project, prompt, or coding agent. We don't see your clicks or anything about what you're building. This helps us have a rough idea of how many folks are using Superpowers and which version of Superpowers they're using. It's 100% optional. To disable this, set the environment variable `SUPERPOWERS_DISABLE_TELEMETRY` to any true value. Superpowers also honors Claude Code's `DISABLE_TELEMETRY` and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` opt-outs. Because skills and plugins don't provide any feedback to creators, we have no idea how many of you are using Superpowers. By default, the Prime Radiant logo on brainstorming's optional visual companion feature is loaded from our website. It includes the version of Superpowers in use. It does not include any details about your project, prompt, or coding agent. We don't see your clicks or anything about what you're building. This helps us have a rough idea of how many folks are using Superpowers and which version of Superpowers they're using. It's 100% optional. To disable this, set the environment variable `SUPERPOWERS_DISABLE_TELEMETRY` to any true value. Superpowers also honors Claude Code's `DISABLE_TELEMETRY` and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` opt-outs.
## Community
Superpowers is built by [Jesse Vincent](https://blog.fsck.com) and the rest of the folks at [Prime Radiant](https://primeradiant.com).
- **Discord**: [Join us](https://discord.gg/35wsABTejz) for community support, questions, and sharing what you're building with Superpowers
- **Issues**: https://github.com/obra/superpowers/issues
- **Release announcements**: [Sign up](https://primeradiant.com/superpowers/) to get notified about new versions

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
@@ -745,10 +747,8 @@ single file that's valid as both a Windows batch script and a Unix shell script.
On Windows, `cmd.exe` runs the batch portion, which locates `bash` (Git for On Windows, `cmd.exe` runs the batch portion, which locates `bash` (Git for
Windows, then `bash` on PATH) and runs the named hook script; if no bash is Windows, then `bash` on PATH) and runs the named hook script; if no bash is
found it exits cleanly so the harness still works, just without injection. On found it exits cleanly so the harness still works, just without injection. On
Unix, the shell executes the leading `:;` lines and `exec`s the hook script Unix, the leading `:` makes the batch block a no-op and the shell runs the
before reaching the batch block (cmd.exe skips those lines as labels). script directly.
Heredocs are banned throughout hooks/ — see issue #571 and the fence test
`tests/hooks/test-no-heredocs-in-hooks.sh`.
Two rules this enforces, which you must respect: Two rules this enforces, which you must respect:
@@ -787,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` |

View File

@@ -65,9 +65,9 @@ The path is quoted because `${CLAUDE_PLUGIN_ROOT}` may contain spaces.
## How `run-hook.cmd` Works at a High Level ## How `run-hook.cmd` Works at a High Level
`run-hook.cmd` is a polyglot script: its first lines start with `:;`, which `run-hook.cmd` is a polyglot script: Windows treats the first block as batch
cmd.exe skips as labels and Unix shells execute — the shell execs the hook commands, while Unix shells treat that block as a no-op heredoc and continue
before ever reaching the batch block that Windows runs. after it.
Do not copy an implementation from this document. Read `hooks/run-hook.cmd` Do not copy an implementation from this document. Read `hooks/run-hook.cmd`
directly when changing the dispatcher, and run `tests/hooks/test-session-start.sh` directly when changing the dispatcher, and run `tests/hooks/test-session-start.sh`
@@ -89,14 +89,10 @@ afterward.
### How it works on Unix (bash/sh) ### How it works on Unix (bash/sh)
1. Each leading `:;` line is a no-op label to cmd.exe but real commands to a 1. `: << 'CMDBLOCK'` opens a heredoc on a no-op command.
POSIX shell. 2. The entire CMD batch block is consumed by the heredoc and ignored.
2. The shell checks bash exists (silent exit 0 if not), resolves the script 3. After `CMDBLOCK`, bash resolves the script directory and `exec`s the named
directory CDPATH-proof, and `exec`s the named extensionless script — it extensionless script directly.
never reads the batch block at all.
3. Heredocs are banned in this file and all hooks/ executables (issue #571:
bash >= 5.1 pre-fork pipe writes deadlock on macOS under pipe pressure);
`tests/hooks/test-no-heredocs-in-hooks.sh` enforces the ban.
### Key design decisions ### Key design decisions

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

View File

@@ -1,20 +1,8 @@
:; command -v bash >/dev/null 2>&1 || exit 0 : << 'CMDBLOCK'
:; [ $# -ge 1 ] || exit 0
:; SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
:; SCRIPT_NAME="$1"; shift; exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
@echo off @echo off
REM Cross-platform polyglot wrapper for hook scripts. REM Cross-platform polyglot wrapper for hook scripts.
REM On Unix: POSIX shells execute the ":;" lines above and exec away before REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
REM reaching this batch block (":" is a no-op; cmd.exe treats lines starting REM On Unix: the shell interprets this as a script (: is a no-op in bash).
REM with ":" as labels and skips them). If bash or the script name is
REM missing, the wrapper exits 0 silently - hooks are optional context, never
REM a session breaker.
REM On Windows: cmd.exe runs this batch portion, which finds and calls bash.
REM
REM Heredocs are banned in this file and every hooks/ executable: bash 5.1+
REM delivers them via a pre-fork pipe write that deadlocks on macOS under
REM pipe pressure (issue #571). tests/hooks/test-no-heredocs-in-hooks.sh is
REM the fence.
REM REM
REM Hook scripts use extensionless filenames (e.g. "session-start" not REM Hook scripts use extensionless filenames (e.g. "session-start" not
REM "session-start.sh") so Claude Code's Windows auto-detection -- which REM "session-start.sh") so Claude Code's Windows auto-detection -- which
@@ -47,4 +35,12 @@ if %ERRORLEVEL% equ 0 (
) )
REM No bash found - exit silently rather than error REM No bash found - exit silently rather than error
REM (plugin still works, just without SessionStart context injection)
exit /b 0 exit /b 0
CMDBLOCK
# Unix: run the named script directly
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_NAME="$1"
shift
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"

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

@@ -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,25 +52,37 @@ 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")
PY PY

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

@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Heredocs are banned from the hook delivery chain: bash >= 5.1 delivers
# them through a pre-fork pipe write that deadlocks on macOS under pipe
# pressure (issue #571; the #571 class). This fence fails the moment one
# returns. The operator is spelled out of a variable so this file does not
# trip its own check.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
OP='<''<'
FAILURES=0
for f in "$REPO_ROOT"/hooks/*; do
case "$f" in *.json) continue;; esac
[ -f "$f" ] || continue
if hits="$(grep -nE "$OP" "$f")"; then
echo " [FAIL] heredoc operator in ${f#"$REPO_ROOT"/}:"
printf '%s\n' "$hits" | sed 's/^/ /'
FAILURES=$((FAILURES + 1))
else
echo " [PASS] ${f#"$REPO_ROOT"/} is heredoc-free"
fi
done
if [ "$FAILURES" -gt 0 ]; then
echo "STATUS: FAILED ($FAILURES file(s))"
exit 1
fi
echo "STATUS: PASSED"

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"

View File

@@ -184,15 +184,6 @@ assert_command_output \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \ CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$WRAPPER_UNDER_TEST" session-start bash "$WRAPPER_UNDER_TEST" session-start
# With no bash available, the wrapper must fail open: silent, exit 0.
if output="$(env -i PATH=/nonexistent /bin/sh "$WRAPPER_UNDER_TEST" session-start 2>&1)" \
&& [ -z "$output" ]; then
pass "wrapper with no bash on PATH is silent and exits 0"
else
fail "wrapper with no bash on PATH is silent and exits 0"
printf '%s\n' "$output" | head -3 | sed 's/^/ /'
fi
cursor_home="$(make_home cursor)" cursor_home="$(make_home cursor)"
assert_command_output \ assert_command_output \
"Cursor emits top-level additional_context only" \ "Cursor emits top-level additional_context only" \