Compare commits

...

12 Commits

Author SHA1 Message Date
Drew Ritter
935de5d5ec Simplify Codex hook entrypoint 2026-05-14 15:51:53 -07:00
Drew Ritter
f5c43d6055 test: add Codex native hook drill coverage 2026-05-13 18:21:55 -07:00
Drew Ritter
51514f86c8 feat: support Codex native plugin hooks 2026-05-13 18:21:07 -07:00
Drew Ritter
a57b6c5dc1 docs: plan Codex native hooks implementation 2026-05-13 18:19:42 -07:00
Drew Ritter
de320dce01 docs: record Codex hook contract spike 2026-05-13 18:19:42 -07:00
Drew Ritter
62b64d1674 docs: refine Codex hooks spec after review 2026-05-13 18:19:42 -07:00
Drew Ritter
09f3871be2 docs: specify Codex native hooks parity 2026-05-13 18:19:42 -07:00
Drew Ritter
49bf5ad6dc Align Pi mapping with action vocabulary 2026-05-13 17:58:46 -07:00
Drew Ritter
4bd0973879 Bump evals submodule for Pi backend 2026-05-13 17:58:46 -07:00
Jesse Vincent
452f1ed40b chore: keep pi extension under .pi 2026-05-13 17:58:46 -07:00
Jesse Vincent
cafbc5a4bd feat: add pi superpowers package extension 2026-05-13 17:58:46 -07:00
Jesse Vincent
da35948daf docs: plan pi extension and evals work 2026-05-13 17:58:46 -07:00
15 changed files with 825 additions and 16 deletions

View File

@@ -21,6 +21,7 @@
"workflow" "workflow"
], ],
"skills": "./skills/", "skills": "./skills/",
"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

@@ -0,0 +1,121 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const EXTREMELY_IMPORTANT_MARKER = "<EXTREMELY_IMPORTANT>";
const BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for pi";
const extensionDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(extensionDir, "../..");
const skillsDir = resolve(packageRoot, "skills");
const bootstrapSkillPath = resolve(skillsDir, "using-superpowers", "SKILL.md");
let cachedBootstrap: string | null | undefined;
export default function superpowersPiExtension(pi: ExtensionAPI) {
let injectBootstrap = true;
pi.on("resources_discover", async () => ({
skillPaths: [skillsDir],
}));
pi.on("session_start", async () => {
injectBootstrap = true;
});
pi.on("session_compact", async () => {
injectBootstrap = true;
});
pi.on("agent_end", async () => {
injectBootstrap = false;
});
pi.on("context", async (event) => {
if (!injectBootstrap) return;
if (event.messages.some(messageContainsBootstrap)) return;
const bootstrap = getBootstrapContent();
if (!bootstrap) return;
const bootstrapMessage = {
role: "user" as const,
content: [{ type: "text" as const, text: bootstrap }],
timestamp: Date.now(),
};
const insertAt = firstNonCompactionSummaryIndex(event.messages);
return {
messages: [
...event.messages.slice(0, insertAt),
bootstrapMessage,
...event.messages.slice(insertAt),
],
};
});
}
function getBootstrapContent(): string | null {
if (cachedBootstrap !== undefined) return cachedBootstrap;
try {
const skillContent = readFileSync(bootstrapSkillPath, "utf8");
const body = stripFrontmatter(skillContent);
cachedBootstrap = `${EXTREMELY_IMPORTANT_MARKER}
${BOOTSTRAP_MARKER}
You have superpowers.
The using-superpowers skill content is included below and is already loaded for this Pi session. Follow it now. Do not try to load using-superpowers again.
${body}
${piToolMapping()}
</EXTREMELY_IMPORTANT>`;
return cachedBootstrap;
} catch {
cachedBootstrap = null;
return null;
}
}
function stripFrontmatter(content: string): string {
const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
return (match ? match[1] : content).trim();
}
function piToolMapping(): string {
return `## Pi tool mapping
Pi has native skills but does not expose Claude Code's \`Skill\` tool. When a Superpowers instruction says to invoke a skill, use Pi's native skill system instead: load the relevant \`SKILL.md\` with \`read\` when the skill applies, or let a human invoke \`/skill:name\` explicitly.
Pi's built-in coding tools are lowercase: \`read\`, \`write\`, \`edit\`, \`bash\`, plus optional \`grep\`, \`find\`, and \`ls\`. Use those for the corresponding actions: read a file, create or edit files, run shell commands, search file contents, find files by name, and list directories.
Pi does not ship a standard subagent tool. If a subagent tool such as \`subagent\` from \`pi-subagents\` is available, use it for Superpowers subagent workflows. If no subagent tool is available, do the work in this session or explain the missing capability instead of inventing \`Task\` calls.
Pi does not ship a standard task-list tool. If an installed todo/task tool is available, use it. Otherwise track work in plan files or a repo-local \`TODO.md\` when task tracking is needed. Treat older \`TodoWrite\` references as this task-tracking action.`;
}
function messageContainsBootstrap(message: unknown): boolean {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") return content.includes(BOOTSTRAP_MARKER);
if (!Array.isArray(content)) return false;
return content.some((part) => {
return (
part &&
typeof part === "object" &&
(part as { type?: unknown }).type === "text" &&
typeof (part as { text?: unknown }).text === "string" &&
(part as { text: string }).text.includes(BOOTSTRAP_MARKER)
);
});
}
function firstNonCompactionSummaryIndex(messages: unknown[]): number {
let index = 0;
while ((messages[index] as { role?: unknown } | undefined)?.role === "compactionSummary") {
index += 1;
}
return index;
}

View File

@@ -4,7 +4,7 @@ Superpowers is a complete software development methodology for your coding agent
## Quickstart ## Quickstart
Give your agent Superpowers: [Claude Code](#claude-code), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [OpenCode](#opencode). Give your agent Superpowers: [Claude Code](#claude-code), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [OpenCode](#opencode), [Pi](#pi).
## How it works ## How it works
@@ -151,6 +151,22 @@ already use it in another harness.
- Detailed docs: [docs/README.opencode.md](docs/README.opencode.md) - Detailed docs: [docs/README.opencode.md](docs/README.opencode.md)
### Pi
Install Superpowers as a Pi package from this repository:
```bash
pi install git:github.com/obra/superpowers
```
For local development, run Pi with this checkout loaded as a temporary package:
```bash
pi -e /path/to/superpowers
```
The Pi package loads the Superpowers skills and a small extension that injects the `using-superpowers` bootstrap at session startup and again after compaction. Pi has native skills, so no compatibility `Skill` tool is required. Subagent and task-list tools remain optional Pi companion packages.
## The Basic Workflow ## The Basic Workflow
1. **brainstorming** - Activates before writing code. Refines rough ideas through questions, explores alternatives, presents design in sections for validation. Saves design document. 1. **brainstorming** - Activates before writing code. Refines rough ideas through questions, explores alternatives, presents design in sections for validation. Saves design document.

View File

@@ -0,0 +1,143 @@
# Pi Extension and Evals Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add first-class Pi package support for Superpowers and add Pi as a Drill eval backend.
**Architecture:** The Pi package is declared in the root `package.json` and loads existing `skills/` plus a small Pi extension. The extension injects the `using-superpowers` bootstrap into provider context as a user-role message on session startup and after compaction, with Pi-specific tool mapping. Drill gains a `pi` backend, Pi session-log normalization, and tests.
**Tech Stack:** Pi TypeScript extension API, Node built-in test runner, Drill Python eval harness, pytest.
---
### Task 1: Pi package manifest and extension tests
**Files:**
- Modify: `package.json`
- Create: `tests/pi/test-pi-extension.mjs`
- [ ] **Step 1: Write failing package/extension tests**
Create `tests/pi/test-pi-extension.mjs` with tests that import `extensions/superpowers.ts`, register fake Pi handlers, and assert:
- root `package.json` has `keywords` containing `pi-package`
- root `package.json` has `pi.skills: ["./skills"]`
- root `package.json` has `pi.extensions: ["./extensions/superpowers.ts"]`
- the extension registers `resources_discover`, `session_start`, `session_compact`, `context`, and `agent_end`
- startup `context` injects exactly one user-role bootstrap message
- `agent_end` clears startup injection
- `session_compact` re-enables injection
- the extension does not register `session_before_compact`
- [ ] **Step 2: Run tests and verify RED**
Run: `node --experimental-strip-types --test tests/pi/test-pi-extension.mjs`
Expected: FAIL because `extensions/superpowers.ts` does not exist and `package.json` lacks the `pi` manifest.
- [ ] **Step 3: Implement manifest fields**
Update `package.json` with `description`, `keywords`, `pi.extensions`, and `pi.skills` while preserving existing `name`, `version`, `type`, and `main`.
- [ ] **Step 4: Implement `extensions/superpowers.ts`**
Create a zero-runtime-dependency extension that:
- locates the package root from `import.meta.url`
- reads `skills/using-superpowers/SKILL.md`
- strips YAML frontmatter
- appends Pi-specific tool mapping
- exposes `resources_discover` with the skills path
- marks bootstrap pending on `session_start` and `session_compact`
- injects a user-role bootstrap message in `context`
- inserts post-compact bootstrap after leading `compactionSummary` messages
- clears pending bootstrap on `agent_end`
- [ ] **Step 5: Run tests and verify GREEN**
Run: `node --experimental-strip-types --test tests/pi/test-pi-extension.mjs`
Expected: PASS.
### Task 2: Pi tool mapping reference
**Files:**
- Create: `skills/using-superpowers/references/pi-tools.md`
- Modify: `tests/pi/test-pi-extension.mjs`
- [ ] **Step 1: Write failing test for Pi reference doc**
Add assertions that `skills/using-superpowers/references/pi-tools.md` exists and documents mappings for `Skill`, `Task`, `TodoWrite`, and built-in tool names.
- [ ] **Step 2: Run tests and verify RED**
Run: `node --experimental-strip-types --test tests/pi/test-pi-extension.mjs`
Expected: FAIL because `pi-tools.md` does not exist.
- [ ] **Step 3: Add Pi reference doc**
Create `skills/using-superpowers/references/pi-tools.md` explaining Pi-native skills, optional `pi-subagents`, no canonical todo/tasklist plugin, and built-in lowercase tools.
- [ ] **Step 4: Run tests and verify GREEN**
Run: `node --experimental-strip-types --test tests/pi/test-pi-extension.mjs`
Expected: PASS.
### Task 3: Drill Pi backend and session log normalization
**Files:**
- Create: `evals/backends/pi.yaml`
- Modify: `evals/drill/backend.py`
- Modify: `evals/drill/engine.py`
- Modify: `evals/drill/normalizer.py`
- Modify: `evals/tests/test_backend.py`
- Modify: `evals/tests/test_normalizer.py`
- [ ] **Step 1: Write failing backend/normalizer tests**
Add pytest coverage for:
- `load_backend("pi")` returns `family == "pi"`
- Pi backend command starts with `pi` and includes `-e ${SUPERPOWERS_ROOT}`
- `_resolve_log_dir()` for Pi points under `~/.pi/agent/sessions`
- `filter_pi_logs_by_cwd()` keeps only session files whose header `cwd` matches the scenario workdir
- `normalize_pi_logs()` extracts `toolCall` blocks from Pi assistant session entries and maps built-in lowercase tools to canonical names
- [ ] **Step 2: Run tests and verify RED**
Run: `uv run pytest evals/tests/test_backend.py evals/tests/test_normalizer.py -q`
Expected: FAIL because the Pi backend and normalizer do not exist.
- [ ] **Step 3: Add `evals/backends/pi.yaml`**
Configure the backend to run `pi -e ${SUPERPOWERS_ROOT}`, use permissive TUI readiness, `/quit` shutdown, and Pi session log location.
- [ ] **Step 4: Implement Pi family support**
Update `Backend.family`, `Engine._resolve_log_dir`, `Engine._collect_tool_calls`, and `normalizer.py` with Pi log filtering and normalizing.
- [ ] **Step 5: Run tests and verify GREEN**
Run: `uv run pytest evals/tests/test_backend.py evals/tests/test_normalizer.py -q`
Expected: PASS.
### Task 4: Documentation and full verification
**Files:**
- Modify: `README.md`
- Modify: `evals/README.md`
- [ ] **Step 1: Document Pi install and eval backend**
Add Pi to README quickstart/install list and add backend entry/usage to `evals/README.md`.
- [ ] **Step 2: Run verification**
Run:
```bash
node --experimental-strip-types --test tests/pi/test-pi-extension.mjs
uv run pytest evals/tests/test_backend.py evals/tests/test_setup.py evals/tests/test_normalizer.py -q
```
Expected: all tests pass.

2
evals

Submodule evals updated: f7ac1941d5...29957de826

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

@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex",
"async": false
}
]
}
]
}
}

View File

@@ -7,13 +7,6 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Check if legacy skills directory exists and build warning
warning_message=""
legacy_skills_dir="${HOME}/.config/superpowers/skills"
if [ -d "$legacy_skills_dir" ]; then
warning_message="\n\n<important-reminder>IN YOUR FIRST REPLY AFTER SEEING THIS MESSAGE YOU MUST TELL THE USER:⚠️ **WARNING:** Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead. To make this message go away, remove ~/.config/superpowers/skills</important-reminder>"
fi
# Read using-superpowers content # Read using-superpowers content
using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill") using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill")
@@ -31,8 +24,7 @@ escape_for_json() {
} }
using_superpowers_escaped=$(escape_for_json "$using_superpowers_content") using_superpowers_escaped=$(escape_for_json "$using_superpowers_content")
warning_escaped=$(escape_for_json "$warning_message") session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\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:**\n\n${using_superpowers_escaped}\n</EXTREMELY_IMPORTANT>"
session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\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:**\n\n${using_superpowers_escaped}\n\n${warning_escaped}\n</EXTREMELY_IMPORTANT>"
# Output context injection as JSON. # Output context injection as JSON.
# Cursor hooks expect additional_context (snake_case). # Cursor hooks expect additional_context (snake_case).

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

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Codex SessionStart hook for superpowers plugin
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill")
escape_for_json() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
using_superpowers_escaped=$(escape_for_json "$using_superpowers_content")
session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, follow the Codex skill-loading instructions in that skill:**\n\n${using_superpowers_escaped}\n</EXTREMELY_IMPORTANT>"
printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context"
exit 0

View File

@@ -1,6 +1,23 @@
{ {
"name": "superpowers", "name": "superpowers",
"version": "5.1.0", "version": "5.1.0",
"description": "Superpowers skills and runtime bootstrap for coding agents",
"type": "module", "type": "module",
"main": ".opencode/plugins/superpowers.js" "main": ".opencode/plugins/superpowers.js",
"keywords": [
"pi-package",
"skills",
"tdd",
"debugging",
"collaboration",
"workflow"
],
"pi": {
"extensions": [
"./.pi/extensions/superpowers.ts"
],
"skills": [
"./skills"
]
}
} }

View File

@@ -70,7 +70,6 @@ EXCLUDES=(
"/commands/" "/commands/"
"/docs/" "/docs/"
"/evals/" "/evals/"
"/hooks/"
"/lib/" "/lib/"
"/scripts/" "/scripts/"
"/tests/" "/tests/"
@@ -420,7 +419,7 @@ if [[ $BOOTSTRAP -eq 1 ]]; then
COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT" COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION). PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\` and \`assets/\`. Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\`, \`assets/\`, and \`hooks/\`.
Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\` Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
@@ -430,7 +429,7 @@ else
COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT" COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION). PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
Copies the tracked plugin files from upstream, including the committed Codex manifest and assets. Copies the tracked plugin files from upstream, including the committed Codex manifest, assets, and hooks.
Run via: \`scripts/sync-to-codex-plugin.sh\` Run via: \`scripts/sync-to-codex-plugin.sh\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA

View File

@@ -41,7 +41,7 @@ If CLAUDE.md, GEMINI.md, or AGENTS.md says "don't use TDD" and a skill says "alw
## Platform Adaptation ## Platform Adaptation
Skills speak in actions ("dispatch a subagent", "create a todo", "read a file") rather than naming any one runtime's tools. For per-platform tool equivalents and instructions-file conventions, see [claude-code-tools.md](references/claude-code-tools.md), [codex-tools.md](references/codex-tools.md), [copilot-tools.md](references/copilot-tools.md), and [gemini-tools.md](references/gemini-tools.md). Gemini CLI users get the tool mapping loaded automatically via GEMINI.md. Skills speak in actions ("dispatch a subagent", "create a todo", "read a file") rather than naming any one runtime's tools. For per-platform tool equivalents and instructions-file conventions, see [claude-code-tools.md](references/claude-code-tools.md), [codex-tools.md](references/codex-tools.md), [copilot-tools.md](references/copilot-tools.md), [gemini-tools.md](references/gemini-tools.md), and [pi-tools.md](references/pi-tools.md). Gemini CLI users get the tool mapping loaded automatically via GEMINI.md.
# Using Skills # Using Skills

View File

@@ -0,0 +1,28 @@
# Pi Tool Mapping
Skills speak in actions ("dispatch a subagent", "create a todo", "read a file"). On Pi these resolve to the tools below.
| Action skills request | Pi equivalent |
| --- | --- |
| Invoke a skill | Pi native skills: load the relevant `SKILL.md` with `read`, or let the human use `/skill:name` |
| Read a file | `read` |
| Create a file | `write` |
| Edit a file | `edit` |
| Run a shell command | `bash` |
| Search file contents | `grep` when active; otherwise `bash` with `rg`/`grep` |
| Find files by name | `find` or `bash` with shell globs |
| List files and subdirectories | `ls` when active; otherwise `bash` with `ls` |
| Dispatch a subagent (`Subagent (general-purpose):` template) | Use an installed subagent tool such as `subagent` from `pi-subagents` if available |
| Task tracking ("create a todo", "mark complete") | Use an installed todo/task tool if available, otherwise track tasks in the plan or `TODO.md` |
## Skills
Pi discovers skills from configured skill directories and installed Pi packages. A Superpowers Pi package should expose `skills/` through its `pi.skills` manifest entry. Pi does not expose Claude Code's `Skill` tool, but the agent should still follow the Superpowers rule: when a skill applies, load and follow it before responding.
## Subagents
Pi core does not ship a standard subagent tool. The `pi-subagents` package is a strong optional companion and provides a `subagent` tool with single-agent, chain, parallel, async, forked-context, and resume/status workflows. If no subagent tool is available, do not fabricate `Task` calls; execute sequentially in the current session or explain that the optional subagent capability is not installed.
## Task lists
Pi core does not ship a standard task-list tool. If a todo/task extension is installed, use its documented tool. Otherwise use Superpowers plan files, checklists in Markdown, or a repo-local `TODO.md` for task tracking. Older Superpowers docs may refer to `TodoWrite`; treat that as the task-tracking action above.

View File

@@ -178,6 +178,7 @@ write_upstream_fixture() {
"$repo/.private-journal" \ "$repo/.private-journal" \
"$repo/assets" \ "$repo/assets" \
"$repo/evals/drill" \ "$repo/evals/drill" \
"$repo/hooks" \
"$repo/scripts" \ "$repo/scripts" \
"$repo/skills/example" "$repo/skills/example"
@@ -218,6 +219,40 @@ EOF
printf 'png fixture\n' > "$repo/assets/app-icon.png" printf 'png fixture\n' > "$repo/assets/app-icon.png"
printf 'eval harness fixture\n' > "$repo/evals/drill/README.md" printf 'eval harness fixture\n' > "$repo/evals/drill/README.md"
cat > "$repo/hooks/hooks-codex.json" <<'EOF'
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex",
"async": false
}
]
}
]
}
}
EOF
cat > "$repo/hooks/session-start" <<'EOF'
#!/usr/bin/env sh
echo "session-start fixture"
EOF
cat > "$repo/hooks/session-start-codex" <<'EOF'
#!/usr/bin/env sh
echo "session-start-codex fixture"
EOF
cat > "$repo/hooks/run-hook.cmd" <<'EOF'
@echo off
echo run-hook fixture
EOF
chmod +x "$repo/hooks/session-start" "$repo/hooks/session-start-codex" "$repo/hooks/run-hook.cmd"
cat > "$repo/skills/example/SKILL.md" <<'EOF' cat > "$repo/skills/example/SKILL.md" <<'EOF'
# Example Skill # Example Skill
@@ -236,6 +271,10 @@ EOF
assets/app-icon.png \ assets/app-icon.png \
assets/superpowers-small.svg \ assets/superpowers-small.svg \
evals/drill/README.md \ evals/drill/README.md \
hooks/hooks-codex.json \
hooks/run-hook.cmd \
hooks/session-start \
hooks/session-start-codex \
package.json \ package.json \
scripts/sync-to-codex-plugin.sh \ scripts/sync-to-codex-plugin.sh \
skills/example/SKILL.md skills/example/SKILL.md
@@ -293,6 +332,7 @@ write_synced_destination_fixture() {
"$repo/plugins/superpowers/.codex-plugin" \ "$repo/plugins/superpowers/.codex-plugin" \
"$repo/plugins/superpowers/.private-journal" \ "$repo/plugins/superpowers/.private-journal" \
"$repo/plugins/superpowers/assets" \ "$repo/plugins/superpowers/assets" \
"$repo/plugins/superpowers/hooks" \
"$repo/plugins/superpowers/skills/example/agents" \ "$repo/plugins/superpowers/skills/example/agents" \
"$repo/plugins/superpowers/skills/example" "$repo/plugins/superpowers/skills/example"
@@ -309,6 +349,40 @@ EOF
printf 'png fixture\n' > "$repo/plugins/superpowers/assets/app-icon.png" printf 'png fixture\n' > "$repo/plugins/superpowers/assets/app-icon.png"
cat > "$repo/plugins/superpowers/hooks/hooks-codex.json" <<'EOF'
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start-codex",
"async": false
}
]
}
]
}
}
EOF
cat > "$repo/plugins/superpowers/hooks/session-start" <<'EOF'
#!/usr/bin/env sh
echo "session-start fixture"
EOF
cat > "$repo/plugins/superpowers/hooks/session-start-codex" <<'EOF'
#!/usr/bin/env sh
echo "session-start-codex fixture"
EOF
cat > "$repo/plugins/superpowers/hooks/run-hook.cmd" <<'EOF'
@echo off
echo run-hook fixture
EOF
chmod +x "$repo/plugins/superpowers/hooks/session-start" "$repo/plugins/superpowers/hooks/session-start-codex" "$repo/plugins/superpowers/hooks/run-hook.cmd"
cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF' cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
# Example Skill # Example Skill
@@ -327,6 +401,10 @@ EOF
plugins/superpowers/.codex-plugin/plugin.json \ plugins/superpowers/.codex-plugin/plugin.json \
plugins/superpowers/assets/app-icon.png \ plugins/superpowers/assets/app-icon.png \
plugins/superpowers/assets/superpowers-small.svg \ plugins/superpowers/assets/superpowers-small.svg \
plugins/superpowers/hooks/hooks-codex.json \
plugins/superpowers/hooks/run-hook.cmd \
plugins/superpowers/hooks/session-start \
plugins/superpowers/hooks/session-start-codex \
plugins/superpowers/skills/example/agents/openai.yaml \ plugins/superpowers/skills/example/agents/openai.yaml \
plugins/superpowers/skills/example/SKILL.md \ plugins/superpowers/skills/example/SKILL.md \
plugins/superpowers/.private-journal/keep.txt plugins/superpowers/.private-journal/keep.txt
@@ -542,6 +620,10 @@ main() {
assert_contains "$preview_section" ".codex-plugin/plugin.json" "Preview includes manifest path" assert_contains "$preview_section" ".codex-plugin/plugin.json" "Preview includes manifest path"
assert_contains "$preview_section" "assets/superpowers-small.svg" "Preview includes SVG asset" assert_contains "$preview_section" "assets/superpowers-small.svg" "Preview includes SVG asset"
assert_contains "$preview_section" "assets/app-icon.png" "Preview includes PNG asset" assert_contains "$preview_section" "assets/app-icon.png" "Preview includes PNG asset"
assert_contains "$preview_section" "hooks/hooks-codex.json" "Preview includes Codex hook manifest"
assert_contains "$preview_section" "hooks/session-start" "Preview includes session-start hook"
assert_contains "$preview_section" "hooks/session-start-codex" "Preview includes Codex session-start hook"
assert_contains "$preview_section" "hooks/run-hook.cmd" "Preview includes hook command wrapper"
assert_contains "$preview_section" ".private-journal/keep.txt" "Preview includes tracked ignored file" assert_contains "$preview_section" ".private-journal/keep.txt" "Preview includes tracked ignored file"
assert_not_contains "$preview_section" ".private-journal/leak.txt" "Preview excludes ignored untracked file" assert_not_contains "$preview_section" ".private-journal/leak.txt" "Preview excludes ignored untracked file"
assert_not_contains "$preview_section" "ignored-cache/" "Preview excludes pure ignored directories" assert_not_contains "$preview_section" "ignored-cache/" "Preview excludes pure ignored directories"

240
tests/hooks/test-session-start.sh Executable file
View File

@@ -0,0 +1,240 @@
#!/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_HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start-codex"
WRAPPER_UNDER_TEST="$REPO_ROOT/hooks/run-hook.cmd"
FAILURES=0
TEST_ROOT="$(mktemp -d)"
cleanup() {
rm -rf "$TEST_ROOT"
}
trap cleanup EXIT
pass() {
echo " [PASS] $1"
}
fail() {
echo " [FAIL] $1"
FAILURES=$((FAILURES + 1))
}
make_home() {
local name="$1"
local home="$TEST_ROOT/$name/home"
mkdir -p "$home"
printf '%s\n' "$home"
}
assert_command_output() {
local description="$1"
local shape="$2"
local contains="$3"
local not_contains="$4"
local home="$5"
shift 5
local output
if ! output="$(env -i PATH="${PATH:-}" HOME="$home" "$@" 2>&1)"; then
fail "$description"
echo " hook exited non-zero"
echo "$output" | sed 's/^/ /'
return
fi
if printf '%s' "$output" | \
EXPECT_SHAPE="$shape" \
EXPECT_CONTAINS="$contains" \
EXPECT_NOT_CONTAINS="$not_contains" \
node -e '
const fs = require("fs");
const input = fs.readFileSync(0, "utf8");
let payload;
try {
payload = JSON.parse(input);
} catch (error) {
console.error(`invalid JSON: ${error.message}`);
process.exit(1);
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function fail(message) {
console.error(message);
process.exit(1);
}
const shape = process.env.EXPECT_SHAPE;
let context;
if (shape === "nested") {
if (!hasOwn(payload, "hookSpecificOutput")) {
fail("missing hookSpecificOutput");
}
if (hasOwn(payload, "additional_context") || hasOwn(payload, "additionalContext")) {
fail("nested output also included a top-level context field");
}
const hookOutput = payload.hookSpecificOutput;
if (!hookOutput || typeof hookOutput !== "object" || Array.isArray(hookOutput)) {
fail("hookSpecificOutput is not an object");
}
if (hookOutput.hookEventName !== "SessionStart") {
fail(`unexpected hookEventName: ${hookOutput.hookEventName}`);
}
context = hookOutput.additionalContext;
} else if (shape === "cursor") {
if (hasOwn(payload, "hookSpecificOutput")) {
fail("cursor output included hookSpecificOutput");
}
if (!hasOwn(payload, "additional_context")) {
fail("cursor output missing additional_context");
}
if (hasOwn(payload, "additionalContext")) {
fail("cursor output included additionalContext");
}
context = payload.additional_context;
} else if (shape === "sdk") {
if (hasOwn(payload, "hookSpecificOutput")) {
fail("sdk output included hookSpecificOutput");
}
if (!hasOwn(payload, "additionalContext")) {
fail("sdk output missing additionalContext");
}
if (hasOwn(payload, "additional_context")) {
fail("sdk output included additional_context");
}
context = payload.additionalContext;
} else {
fail(`unknown expected shape: ${shape}`);
}
if (typeof context !== "string" || context.trim() === "") {
fail("injected context was empty");
}
const expectedText = process.env.EXPECT_CONTAINS || "";
if (expectedText && !context.includes(expectedText)) {
fail(`context did not contain expected text: ${expectedText}`);
}
const forbiddenTexts = (process.env.EXPECT_NOT_CONTAINS || "")
.split("\u001f")
.filter(Boolean);
for (const forbiddenText of forbiddenTexts) {
if (context.includes(forbiddenText)) {
fail(`context contained forbidden text: ${forbiddenText}`);
}
}
'; then
pass "$description"
else
fail "$description"
echo " output:"
echo "$output" | sed 's/^/ /'
fi
}
echo "SessionStart hook output tests"
claude_home="$(make_home claude-code)"
assert_command_output \
"Claude Code emits nested SessionStart additionalContext" \
"nested" \
"" \
"" \
"$claude_home" \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$HOOK_UNDER_TEST"
codex_home="$(make_home codex-plugin-hooks)"
codex_data="$TEST_ROOT/codex-plugin-hooks/data"
mkdir -p "$codex_data"
assert_command_output \
"Codex plugin hooks use dedicated script and emit nested SessionStart additionalContext" \
"nested" \
"" \
"" \
"$codex_home" \
PLUGIN_DATA="$codex_data" \
CLAUDE_PLUGIN_DATA="$codex_data" \
PLUGIN_ROOT="$REPO_ROOT" \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$CODEX_HOOK_UNDER_TEST"
codex_wrapper_home="$(make_home codex-wrapper)"
codex_wrapper_data="$TEST_ROOT/codex-wrapper/data"
mkdir -p "$codex_wrapper_data"
assert_command_output \
"Codex wrapper path dispatches to dedicated script" \
"nested" \
"" \
"" \
"$codex_wrapper_home" \
PLUGIN_DATA="$codex_wrapper_data" \
CLAUDE_PLUGIN_DATA="$codex_wrapper_data" \
PLUGIN_ROOT="$REPO_ROOT" \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$WRAPPER_UNDER_TEST" session-start-codex
cursor_home="$(make_home cursor)"
assert_command_output \
"Cursor emits top-level additional_context only" \
"cursor" \
"" \
"" \
"$cursor_home" \
CURSOR_PLUGIN_ROOT="$REPO_ROOT" \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$HOOK_UNDER_TEST"
copilot_home="$(make_home copilot-cli)"
assert_command_output \
"Copilot CLI emits top-level additionalContext only" \
"sdk" \
"" \
"" \
"$copilot_home" \
COPILOT_CLI=1 \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$HOOK_UNDER_TEST"
legacy_home="$(make_home legacy-warning-removed)"
mkdir -p "$legacy_home/.config/superpowers/skills"
assert_command_output \
"SessionStart omits obsolete legacy custom-skill warning" \
"nested" \
"" \
"Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
"$legacy_home" \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$HOOK_UNDER_TEST"
codex_legacy_home="$(make_home codex-legacy-warning-removed)"
codex_legacy_data="$TEST_ROOT/codex-legacy-warning-removed/data"
mkdir -p "$codex_legacy_home/.config/superpowers/skills" "$codex_legacy_data"
assert_command_output \
"Codex SessionStart omits obsolete legacy custom-skill warning" \
"nested" \
"" \
"Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
"$codex_legacy_home" \
PLUGIN_DATA="$codex_legacy_data" \
CLAUDE_PLUGIN_DATA="$codex_legacy_data" \
PLUGIN_ROOT="$REPO_ROOT" \
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
bash "$CODEX_HOOK_UNDER_TEST"
if [[ "$FAILURES" -gt 0 ]]; then
echo "STATUS: FAILED ($FAILURES failure(s))"
exit 1
fi
echo "STATUS: PASSED"

View File

@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import test from 'node:test';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '../..');
const packageJsonPath = resolve(repoRoot, 'package.json');
const extensionPath = resolve(repoRoot, '.pi/extensions/superpowers.ts');
const piToolsPath = resolve(repoRoot, 'skills/using-superpowers/references/pi-tools.md');
async function readPackageJson() {
return JSON.parse(await readFile(packageJsonPath, 'utf8'));
}
async function loadExtension() {
const handlers = new Map();
const pi = {
on(event, handler) {
if (!handlers.has(event)) handlers.set(event, []);
handlers.get(event).push(handler);
},
};
const mod = await import(pathToFileURL(extensionPath).href + `?cachebust=${Date.now()}-${Math.random()}`);
mod.default(pi);
return { handlers };
}
function firstHandler(handlers, event) {
const eventHandlers = handlers.get(event) ?? [];
assert.equal(eventHandlers.length, 1, `expected one ${event} handler`);
return eventHandlers[0];
}
function textOf(message) {
if (typeof message.content === 'string') return message.content;
return message.content
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n');
}
test('package.json declares a pi package with skills and extension resources', async () => {
const pkg = await readPackageJson();
assert.equal(pkg.name, 'superpowers');
assert.ok(pkg.keywords.includes('pi-package'));
assert.deepEqual(pkg.pi.skills, ['./skills']);
assert.deepEqual(pkg.pi.extensions, ['./.pi/extensions/superpowers.ts']);
});
test('extension registers lifecycle hooks without pre-compaction injection', async () => {
const { handlers } = await loadExtension();
for (const event of ['resources_discover', 'session_start', 'session_compact', 'context', 'agent_end']) {
assert.equal((handlers.get(event) ?? []).length, 1, `missing ${event} handler`);
}
assert.equal((handlers.get('session_before_compact') ?? []).length, 0);
});
test('resources_discover contributes the bundled skills directory', async () => {
const { handlers } = await loadExtension();
const discover = firstHandler(handlers, 'resources_discover');
const result = await discover({ type: 'resources_discover', cwd: repoRoot, reason: 'startup' }, {});
assert.deepEqual(result.skillPaths, [resolve(repoRoot, 'skills')]);
});
test('startup context injects the bootstrap as one user message until agent_end', async () => {
const { handlers } = await loadExtension();
const sessionStart = firstHandler(handlers, 'session_start');
const context = firstHandler(handlers, 'context');
const agentEnd = firstHandler(handlers, 'agent_end');
await sessionStart({ type: 'session_start', reason: 'startup' }, {});
const originalMessages = [
{ role: 'user', content: [{ type: 'text', text: 'Let us make a react todo list' }], timestamp: 1 },
];
const result = await context({ type: 'context', messages: originalMessages }, {});
assert.equal(result.messages.length, 2);
assert.equal(result.messages[0].role, 'user');
assert.match(textOf(result.messages[0]), /You have superpowers/);
assert.match(textOf(result.messages[0]), /Pi tool mapping/);
assert.equal(result.messages[1], originalMessages[0]);
const repeatedProviderRequest = await context({ type: 'context', messages: originalMessages }, {});
assert.equal(repeatedProviderRequest.messages.length, 2);
assert.match(textOf(repeatedProviderRequest.messages[0]), /You have superpowers/);
const alreadyInjected = await context({ type: 'context', messages: result.messages }, {});
assert.equal(alreadyInjected, undefined, 'bootstrap should not duplicate when already present');
await agentEnd({ type: 'agent_end', messages: [] }, {});
const afterEnd = await context({ type: 'context', messages: originalMessages }, {});
assert.equal(afterEnd, undefined, 'startup bootstrap should clear after agent_end');
});
test('session_compact injects bootstrap after compaction summaries, not before compaction', async () => {
const { handlers } = await loadExtension();
const sessionCompact = firstHandler(handlers, 'session_compact');
const context = firstHandler(handlers, 'context');
await sessionCompact({ type: 'session_compact', compactionEntry: {}, fromExtension: false }, {});
const summary = { role: 'compactionSummary', summary: 'Prior work summary', tokensBefore: 123, timestamp: 1 };
const user = { role: 'user', content: [{ type: 'text', text: 'Continue' }], timestamp: 2 };
const result = await context({ type: 'context', messages: [summary, user] }, {});
assert.equal(result.messages.length, 3);
assert.equal(result.messages[0], summary);
assert.equal(result.messages[1].role, 'user');
assert.match(textOf(result.messages[1]), /You have superpowers/);
assert.equal(result.messages[2], user);
});
test('pi tools reference documents pi-specific mappings', async () => {
assert.equal(existsSync(piToolsPath), true, 'pi-tools.md should exist');
const text = await readFile(piToolsPath, 'utf8');
for (const expected of ['Skill', 'Task', 'TodoWrite', 'read', 'write', 'edit', 'bash']) {
assert.match(text, new RegExp(expected));
}
});