Compare commits

...

18 Commits

Author SHA1 Message Date
Drew Ritter
63101959b5 feat: add Alpine visual companion mockups 2026-05-27 16:22:48 -07:00
Jesse Vincent
8811b0f2d7 Revert "Make visual-companion.md script paths skill-rooted, not plugin-rooted"
This reverts commit e9f5188289.
2026-05-23 17:01:46 -07:00
Jesse Vincent
d48bec6cc3 Revert "Probe per-user Git Bash and Scoop before falling back to PATH on Windows"
This reverts commit a8f0738e3a.
2026-05-23 17:00:15 -07:00
Jesse Vincent
a8f0738e3a Probe per-user Git Bash and Scoop before falling back to PATH on Windows
Stock Windows 10/11 ships C:\Windows\System32\bash.exe (the WSL
launcher) as the first match for `where bash`. WSL's bash cannot
execute Windows-style script paths, so when Git Bash is installed
outside the two standard system locations -- specifically the
per-user "Only for me" Git for Windows installer
(%LOCALAPPDATA%\Programs\Git) or a Scoop install
(%USERPROFILE%\scoop\apps\git\current\usr\bin) -- run-hook.cmd
silently fails: WSL prints "Windows Subsystem for Linux must be
updated", the script returns 0, and Superpowers' SessionStart
bootstrap is never injected. From the user's perspective skills
auto-trigger inconsistently or not at all, with no surfaced error.

Add explicit probes for both locations between the existing system-
wide Git for Windows checks and the `where bash` fallback. Also add
a comment to the fallback documenting the WSL-launcher trap so future
maintainers understand why the explicit probes must come first.

Verified on a Windows 11 VM (dockur/windows 11, Git Bash 2.x, Node
22):
- System Git present: existing probe still matches (no regression)
- System Git absent, per-user Git present via junction: new probe
  matches, hook produces valid 6422-byte JSON, exit 0
- All Git probes absent: confirmed WSL trap fires
  ("Windows Subsystem for Linux must be updated") and the hook exits 0
  silently, demonstrating the original bug

Existing tests/hooks/test-session-start.sh still passes on macOS (7/7).

Reported by @ytchenak in #1607.

Co-authored-by: ytchenak <ytchenak@users.noreply.github.com>
Closes #1607.
2026-05-23 16:58:56 -07:00
Jesse Vincent
f36bad5b78 Pipe SessionStart hook printf through cat to absorb EPIPE on Windows
On Windows + Git Bash, the SessionStart hook prints a confusing
diagnostic at every startup ("printf: write error: Permission denied")
when Claude Code closes the hook's stdout pipe before the printf has
finished writing. The hook still runs to completion and context still
gets injected, but the diagnostic surfaces every session because
Git Bash's printf reports EPIPE as "Permission denied" (not "Broken
pipe" like Linux) and our `set -euo pipefail` lets that error escape.

Piping each printf through `cat` makes the external cat process the
recipient of any SIGPIPE / EPIPE. cat's failure does not propagate to
the parent bash under pipefail because cat is the last command in the
pipeline and exits cleanly when the pipe stays open long enough to
hold the data. On macOS/Linux the cat passthrough is transparent (no
behavior change, no measurable cost).

Verified:
- Existing tests/hooks/test-session-start.sh: 7/7 pass on macOS
- Manual run on Windows 11 + Git Bash 5.2 + Node 22 produces valid JSON,
  clean stderr, and exit 0
- JSON output is byte-identical to the unpatched hook

Reported by @silvertakana in #1612, attribution preserved in the
Co-authored-by trailer below — this is the same fix shape the original
PR proposed.

Co-authored-by: silvertakana <silvertakana@users.noreply.github.com>
Closes #1612.
2026-05-23 16:55:46 -07:00
Nick Galatis
21ad401e90 fix(systematic-debugging): defuse Claude Code ultrathink keyword scanner trigger (#1558)
The "Signals You're Doing It Wrong" bullet in systematic-debugging/SKILL.md
contains the literal token Claude Code's runtime scans for in tool result
bodies. Every Skill-tool invocation of this skill caused the harness to
inject a spurious system-reminder claiming the user requested deeper
reasoning, silently bumping every session into extended thinking.

Replace the bullet's spelling so the contiguous letter sequence the scanner
matches is broken with a hyphen. The signal text remains recognizable to
the agent and the documented action ("Question fundamentals, not just
symptoms") is unchanged.

Fixes obra/superpowers#1283
2026-05-23 16:51:00 -07:00
Jesse Vincent
e9f5188289 Make visual-companion.md script paths skill-rooted, not plugin-rooted
Issue #1134: agents reading visual-companion.md see bare commands like
`scripts/start-server.sh`, correctly identify the plugin install
directory, then look for `<plugin>/scripts/start-server.sh` instead of
`<plugin>/skills/brainstorming/scripts/start-server.sh`. The file
doesn't exist at the plugin-rooted path, so the agent concludes the
visual companion isn't available and falls back to text-only
brainstorming.

Multiple independent reproductions in the issue thread, plus one user's
agent self-reported: "I assumed the scripts folder was in the root
directory of the plugin, it didn't realize it could have been talking
about the skill folder itself."

Change all `scripts/<file>` references in visual-companion.md to
`skills/brainstorming/scripts/<file>`. Agents that correctly identify
the plugin root will now join to the right path.

Closes #1134.
2026-05-23 16:42:13 -07:00
Jesse Vincent
eef50b96f0 Align windows-lifecycle test with current brainstorm server layout
The test had drifted behind three server implementation changes and no
longer ran against the actual server:

- Server entrypoint renamed from server.js to server.cjs; the test still
  invoked node on server.js and failed with MODULE_NOT_FOUND.
- Server state moved to a state/ subdirectory (state/server-info,
  state/server.pid); the test still waited on .server-info and wrote
  .server.pid at the session root.
- Owner-PID startup validation now keeps the server running when the
  owner PID is dead at startup: it logs owner-pid-invalid, disables
  owner monitoring, and falls back to the idle timeout. The test still
  expected the server to self-terminate within 60s of a dead-at-startup
  owner.

Update file/path references to match the current server, and rewrite
the dead-at-startup test to assert the current behavior: server
survives, log contains owner-pid-invalid, log does not contain a
spurious "owner process exited" line.

Verified locally: 9 passed, 0 failed, 3 skipped (Windows-only).
2026-05-23 16:36:45 -07:00
Jesse Vincent
e1d3f71e0d Convert curly to square brackets in code-reviewer.md placeholders
Matches the style used by the spec-reviewer-prompt.md and
code-quality-reviewer-prompt.md call sites, which already use square
brackets ([VAR] or [VAR — description]). No semantic change — these
placeholders are filled in by the controller; nothing programmatic
substitutes them.
2026-05-23 16:14:24 -07:00
Jesse Vincent
b2212dc913 Scope spec reviewer to task diff and make reviewers read-only
Two problems with the SDD reviewer prompts on dev:

- spec-reviewer-prompt.md never received a git range, so the
  general-purpose subagent had to crawl the entire codebase to find what
  changed. Reporter measured 20-33 minute spec reviews on simple tasks
  (#1538).
- Neither reviewer prompt told the subagent that review is read-only.
  A spec reviewer running `git checkout <parent-sha>` for historical
  comparison silently detached HEAD on the controller's branch, then
  subsequent task commits accumulated on the detached HEAD and were
  effectively orphaned (#1543, reproduced independently in #1543's
  thread).

Add a Git Range to Review section to spec-reviewer-prompt.md that
mirrors the one code-reviewer.md already has, plus a Read-Only Review
section in both reviewer prompt templates stating the principle: do
not mutate the working tree, the index, HEAD, or branch state. Allow
inspecting other revisions via a separate temporary worktree, so the
read-only rule does not block legitimate historical comparison.

Closes #1538.
Closes #1543.
2026-05-23 16:14:05 -07:00
Jesse Vincent
180f009090 @mhat reported that his claude got confused about 'debugging' being named as a skill in the bootstrap 2026-05-21 17:23:25 -04:00
Drew Ritter
8c1f7c5dae Bump superpowers-evals submodule 2026-05-14 16:32:24 -07:00
Drew Ritter
201f945838 [codex] support native Codex plugin hooks (#1540)
* docs: specify Codex native hooks parity

* docs: refine Codex hooks spec after review

* docs: record Codex hook contract spike

* docs: plan Codex native hooks implementation

* feat: support Codex native plugin hooks

* test: add Codex native hook drill coverage

* Simplify Codex hook entrypoint
2026-05-14 15:59:38 -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
29 changed files with 2916 additions and 72 deletions

View File

@@ -21,6 +21,7 @@
"workflow"
],
"skills": "./skills/",
"hooks": "./hooks/hooks-codex.json",
"interface": {
"displayName": "Superpowers",
"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
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
@@ -151,6 +151,22 @@ already use it in another harness.
- 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
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.

View File

@@ -0,0 +1,989 @@
# Visual Companion Alpine Support 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 Alpine-backed interactivity to the existing visual companion screen path without adding a second artifact/prototype system.
**Architecture:** Vendor one pinned Alpine 3.x browser artifact in the brainstorming skill runtime, serve it from a narrow localhost route, and load it from the existing frame template for fragment screens only. Keep the current helper/event model intact, update authoring guidance so agents use Alpine sparingly, and require evidence that the new guidance changes behavior.
**Tech Stack:** Node.js HTTP server, plain HTML/CSS/JavaScript, vendored Alpine.js 3.15.12, shell sync tests, Superpowers skill docs.
---
## Source Material
- Spec: `docs/superpowers/specs/2026-05-08-visual-companion-alpine-design.md`
- Linear: `SUP-215`
- Current branch: `codex/explore-interactive-prototypes`
- Verified Alpine package metadata on 2026-05-08:
- Version: `3.15.12`
- Tarball: `https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz`
- npm integrity: `sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==`
- Vendored file inside tarball: `package/dist/cdn.min.js`
- SHA256 of `package/dist/cdn.min.js`: `57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f`
- License: MIT
- Approval artifact: `SUP-215`
## File Structure
- Create: `skills/brainstorming/scripts/vendor/alpine.js`
- Exact copy of Alpine `package/dist/cdn.min.js` from the pinned npm tarball.
- Create: `skills/brainstorming/scripts/vendor/alpine.provenance.json`
- Machine-readable source URL, package version, vendored path, SHA256, approval artifact, and vendoring date.
- Create: `skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md`
- Human-readable Alpine license notice and refresh command.
- Modify: `skills/brainstorming/scripts/server.cjs`
- Add parsed-path vendor serving for `/vendor/alpine.js`.
- Modify: `skills/brainstorming/scripts/frame-template.html`
- Load Alpine for frame-wrapped fragments and neutralize the footer copy.
- Modify: `tests/brainstorm-server/server.test.js`
- Cover provenance, vendor route behavior, helper injection, frame injection, and full-document/waiting-page boundaries.
- Modify: `skills/brainstorming/visual-companion.md`
- Update agent-facing guidance from selection-first/static mockups to compact Alpine-backed interactive mockups.
- Modify: `scripts/sync-to-codex-plugin.sh`
- Surface vendored Alpine provenance in generated Codex plugin sync PR bodies.
- Modify: `tests/codex-plugin-sync/test-sync-to-codex-plugin.sh`
- Ensure nested skill-local scripts and vendor files survive root `/scripts/` exclusion and generated PR-body source includes the vendored dependency note.
## Task 1: Vendor Alpine and Add Provenance Tests
**Files:**
- Create: `skills/brainstorming/scripts/vendor/alpine.js`
- Create: `skills/brainstorming/scripts/vendor/alpine.provenance.json`
- Create: `skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md`
- Modify: `tests/brainstorm-server/server.test.js`
- [ ] **Step 1: Write the failing provenance test**
Add this import alongside the existing `require` block:
```js
const crypto = require('crypto');
```
Add these constants near the existing `SERVER_PATH`, `TEST_PORT`, and directory constants in `tests/brainstorm-server/server.test.js`:
```js
const ALPINE_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/alpine.js');
const ALPINE_PROVENANCE_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/alpine.provenance.json');
const ALPINE_NOTICES_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md');
```
Add this helper below `fetch(url)`:
```js
function sha256File(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
```
Add this test block at the start of `runTests()`, before `// ========== Server Startup ==========`:
```js
// ========== Vendored Alpine ==========
console.log('\n--- Vendored Alpine ---');
await test('vendored Alpine provenance is complete and matches artifact hash', () => {
assert(fs.existsSync(ALPINE_PATH), 'alpine.js should exist');
assert(fs.existsSync(ALPINE_PROVENANCE_PATH), 'alpine.provenance.json should exist');
assert(fs.existsSync(ALPINE_NOTICES_PATH), 'THIRD_PARTY_NOTICES.md should exist');
const provenance = JSON.parse(fs.readFileSync(ALPINE_PROVENANCE_PATH, 'utf-8'));
assert.strictEqual(provenance.name, 'alpinejs');
assert.strictEqual(provenance.version, '3.15.12');
assert.strictEqual(provenance.license, 'MIT');
assert.strictEqual(provenance.sourceUrl, 'https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz');
assert.strictEqual(provenance.sourcePackagePath, 'package/dist/cdn.min.js');
assert.strictEqual(provenance.localPath, 'skills/brainstorming/scripts/vendor/alpine.js');
assert.strictEqual(provenance.sha256, '57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f');
assert.strictEqual(provenance.approvalArtifact, 'SUP-215');
assert.strictEqual(sha256File(ALPINE_PATH), provenance.sha256);
const notices = fs.readFileSync(ALPINE_NOTICES_PATH, 'utf-8');
assert(notices.includes('Alpine.js'), 'Notice should name Alpine.js');
assert(notices.includes('MIT License'), 'Notice should include MIT license text');
assert(notices.includes('curl -fsSL https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz'), 'Notice should include refresh command');
return Promise.resolve();
});
```
- [ ] **Step 2: Run the failing test**
Run:
```bash
cd "$(git rev-parse --show-toplevel)"
node tests/brainstorm-server/server.test.js
```
Expected: FAIL with `alpine.js should exist`.
- [ ] **Step 3: Vendor Alpine from the pinned npm tarball**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
mkdir -p skills/brainstorming/scripts/vendor
tmpdir="$(mktemp -d)"
curl -fsSL https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz -o "$tmpdir/alpinejs-3.15.12.tgz"
tar -xzf "$tmpdir/alpinejs-3.15.12.tgz" -C "$tmpdir" package/dist/cdn.min.js
cp "$tmpdir/package/dist/cdn.min.js" skills/brainstorming/scripts/vendor/alpine.js
rm -rf "$tmpdir"
shasum -a 256 skills/brainstorming/scripts/vendor/alpine.js
```
Expected SHA256:
```text
57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f
```
- [ ] **Step 4: Create provenance metadata**
Create `skills/brainstorming/scripts/vendor/alpine.provenance.json` with this exact JSON:
```json
{
"name": "alpinejs",
"version": "3.15.12",
"license": "MIT",
"sourceUrl": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"sourcePackagePath": "package/dist/cdn.min.js",
"localPath": "skills/brainstorming/scripts/vendor/alpine.js",
"npmIntegrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
"approvalArtifact": "SUP-215",
"vendoredAt": "2026-05-08"
}
```
Create `skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md` with:
````markdown
# Third-Party Notices
## Alpine.js
- Package: `alpinejs`
- Version: `3.15.12`
- Source: `https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz`
- Vendored file: `package/dist/cdn.min.js`
- Local path: `skills/brainstorming/scripts/vendor/alpine.js`
- SHA256: `57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f`
Refresh command:
```bash
cd "$(git rev-parse --show-toplevel)"
tmpdir="$(mktemp -d)"
curl -fsSL https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz -o "$tmpdir/alpinejs-3.15.12.tgz"
tar -xzf "$tmpdir/alpinejs-3.15.12.tgz" -C "$tmpdir" package/dist/cdn.min.js
cp "$tmpdir/package/dist/cdn.min.js" skills/brainstorming/scripts/vendor/alpine.js
shasum -a 256 skills/brainstorming/scripts/vendor/alpine.js
rm -rf "$tmpdir"
```
License:
```text
MIT License
Copyright © 2019-2025 Caleb Porzio and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
````
- [ ] **Step 5: Run the provenance test**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
node tests/brainstorm-server/server.test.js
```
Expected: the vendored Alpine provenance test passes. Later HTTP tests may still fail until Task 2 if they have already been added; do not commit until this command exits 0 after Task 2.
- [ ] **Step 6: Commit Task 1**
After Task 2 also passes the full server test, commit Task 1 and Task 2 together. The vendored file and server route are one behavioral unit.
## Task 2: Serve Alpine and Inject It Into Frame-Wrapped Fragments
**Files:**
- Modify: `skills/brainstorming/scripts/server.cjs`
- Modify: `skills/brainstorming/scripts/frame-template.html`
- Modify: `tests/brainstorm-server/server.test.js`
- [ ] **Step 1: Add failing HTTP and injection tests**
Add this test after `returns Content-Type text/html`:
```js
await test('waiting page does not inject Alpine', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/`);
assert(!res.body.includes('/vendor/alpine.js'), 'Waiting page should not inject Alpine');
});
```
Add these tests after `returns 404 for non-root paths`:
```js
await test('serves vendored Alpine from exact vendor route', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/vendor/alpine.js`);
assert.strictEqual(res.status, 200);
assert(res.headers['content-type'].includes('application/javascript'), 'Should be JavaScript');
assert(res.body.includes('Alpine'), 'Should serve Alpine script content');
});
await test('serves vendored Alpine when query string is present', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/vendor/alpine.js?v=3.15.12`);
assert.strictEqual(res.status, 200);
assert(res.body.includes('Alpine'), 'Should ignore query string for exact vendor pathname');
});
await test('exact-match vendor route rejects non-allowlisted pathnames', async () => {
const paths = [
'/vendor/unknown.js',
'/vendor/alpine.js/extra',
'/vendor/../alpine.js',
'/vendor/%2e%2e/alpine.js',
'/vendor/%2E%2E/alpine.js'
];
for (const requestPath of paths) {
const res = await fetch(`http://localhost:${TEST_PORT}${requestPath}`);
assert.strictEqual(res.status, 404, `${requestPath} should 404`);
}
});
```
This test should assert the actual defense: the route is an exact parsed-pathname
allowlist. Do not describe `/vendor/../alpine.js` as proving filesystem
canonicalization, because the URL parser normalizes that request before the
vendor allowlist sees it.
Update `serves full HTML documents as-is (not wrapped)` with this assertion:
```js
assert(!res.body.includes('/vendor/alpine.js'), 'Should NOT inject Alpine into full documents');
```
Update `wraps content fragments in frame template` with these assertions:
```js
assert(res.body.includes('<script defer src="/vendor/alpine.js"></script>'), 'Fragment should load Alpine');
assert(res.body.includes('Interact with the mockup, then return to the terminal'), 'Frame copy should be neutral');
```
Add this test after `wraps content fragments in frame template`:
```js
await test('preserves Alpine attributes in frame-wrapped fragments', async () => {
const fragment = '<div x-data="{ open: false }"><button @click="open = !open">Toggle</button><div x-show="open">Details</div></div>';
fs.writeFileSync(path.join(CONTENT_DIR, 'alpine-fragment.html'), fragment);
await sleep(300);
const res = await fetch(`http://localhost:${TEST_PORT}/`);
assert(res.body.includes('x-data="{ open: false }"'), 'Should preserve x-data');
assert(res.body.includes('@click="open = !open"'), 'Should preserve @click');
assert(res.body.includes('x-show="open"'), 'Should preserve x-show');
assert(res.body.includes('/vendor/alpine.js'), 'Should include Alpine script');
});
```
- [ ] **Step 2: Run the failing tests**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
node tests/brainstorm-server/server.test.js
```
Expected: FAIL because `/vendor/alpine.js` returns 404 and the frame does not include Alpine yet.
- [ ] **Step 3: Implement exact vendor serving**
In `skills/brainstorming/scripts/server.cjs`, add these constants after `helperInjection`:
```js
const ALPINE_VENDOR_PATH = path.join(__dirname, 'vendor', 'alpine.js');
function loadVendorFile(filePath, name) {
try {
return fs.readFileSync(filePath);
} catch (error) {
throw new Error(
`Failed to load vendored ${name} at ${filePath}; ` +
'run the refresh command in skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md. ' +
error.message
);
}
}
const VENDOR_FILES = new Map([
['/vendor/alpine.js', {
content: loadVendorFile(ALPINE_VENDOR_PATH, 'Alpine'),
contentType: 'application/javascript; charset=utf-8'
}]
]);
```
Add these helpers after `getNewestScreen()`:
```js
function parseRequestUrl(req) {
return new URL(req.url, 'http://localhost');
}
function serveVendorFile(requestUrl, res) {
const vendorFile = VENDOR_FILES.get(requestUrl.pathname);
if (!vendorFile) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': vendorFile.contentType });
res.end(vendorFile.content);
}
```
Change the start of `handleRequest(req, res)` to parse once and use `pathname`:
```js
function handleRequest(req, res) {
touchActivity();
const requestUrl = parseRequestUrl(req);
if (req.method === 'GET' && requestUrl.pathname === '/') {
```
Add the vendor branch before `/files/`:
```js
} else if (req.method === 'GET' && requestUrl.pathname.startsWith('/vendor/')) {
serveVendorFile(requestUrl, res);
} else if (req.method === 'GET' && requestUrl.pathname.startsWith('/files/')) {
const fileName = requestUrl.pathname.slice(7);
```
Keep the rest of the `/files/` branch unchanged except that it now uses `fileName` from `requestUrl.pathname`.
- [ ] **Step 4: Inject Alpine from the frame template**
In `skills/brainstorming/scripts/frame-template.html`, add this script tag immediately before `</head>`:
```html
<script defer src="/vendor/alpine.js"></script>
```
Change the indicator copy to:
```html
<span id="indicator-text">Interact with the mockup, then return to the terminal</span>
```
- [ ] **Step 5: Run the server tests**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
node tests/brainstorm-server/server.test.js
```
Expected: `PASS` and `0 failed`.
- [ ] **Step 6: Commit Tasks 1 and 2**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
git add \
skills/brainstorming/scripts/server.cjs \
skills/brainstorming/scripts/frame-template.html \
skills/brainstorming/scripts/vendor/alpine.js \
skills/brainstorming/scripts/vendor/alpine.provenance.json \
skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md \
tests/brainstorm-server/server.test.js
git commit -m "feat: add Alpine to visual companion runtime"
```
## Task 3: Preserve Alpine Through Codex Plugin Sync
**Files:**
- Modify: `scripts/sync-to-codex-plugin.sh`
- Modify: `tests/codex-plugin-sync/test-sync-to-codex-plugin.sh`
- [ ] **Step 1: Add failing sync fixture coverage**
In `write_upstream_fixture()`, extend the `mkdir -p` block with:
```bash
"$repo/skills/brainstorming/scripts/vendor" \
```
After the example skill fixture, add:
```bash
cat > "$repo/skills/brainstorming/scripts/server.cjs" <<'EOF'
console.log('fixture server')
EOF
cat > "$repo/skills/brainstorming/scripts/helper.js" <<'EOF'
window.fixtureHelper = true
EOF
cat > "$repo/skills/brainstorming/scripts/frame-template.html" <<'EOF'
<html><body><!-- CONTENT --></body></html>
EOF
printf 'fixture alpine\n' > "$repo/skills/brainstorming/scripts/vendor/alpine.js"
cat > "$repo/skills/brainstorming/scripts/vendor/alpine.provenance.json" <<'EOF'
{"name":"alpinejs","version":"3.15.12","localPath":"skills/brainstorming/scripts/vendor/alpine.js","sha256":"fixture","approvalArtifact":"SUP-215"}
EOF
cat > "$repo/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md" <<'EOF'
# Third-Party Notices
Alpine.js fixture notice.
EOF
```
Add these paths to the `git -C "$repo" add` list:
```bash
skills/brainstorming/scripts/server.cjs \
skills/brainstorming/scripts/helper.js \
skills/brainstorming/scripts/frame-template.html \
skills/brainstorming/scripts/vendor/alpine.js \
skills/brainstorming/scripts/vendor/alpine.provenance.json \
skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md \
```
In `write_synced_destination_fixture()`, extend the `mkdir -p` block with:
```bash
"$repo/plugins/superpowers/skills/brainstorming/scripts/vendor" \
```
Add the same fixture files under `plugins/superpowers/skills/brainstorming/scripts/`, then add those paths to the destination `git add` list.
Add these preview assertions after `Preview reflects dirty tracked destination file`:
```bash
assert_contains "$preview_section" "skills/brainstorming/scripts/server.cjs" "Preview includes skill-local server runtime"
assert_contains "$preview_section" "skills/brainstorming/scripts/helper.js" "Preview includes skill-local helper runtime"
assert_contains "$preview_section" "skills/brainstorming/scripts/frame-template.html" "Preview includes skill-local frame template"
assert_contains "$preview_section" "skills/brainstorming/scripts/vendor/alpine.js" "Preview includes vendored Alpine"
assert_contains "$preview_section" "skills/brainstorming/scripts/vendor/alpine.provenance.json" "Preview includes Alpine provenance"
assert_contains "$preview_section" "skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md" "Preview includes Alpine notice"
```
Add these no-op fixture path variables near `noop_openai_metadata_path`:
```bash
local noop_alpine_path
local noop_alpine_provenance_path
local noop_alpine_notice_path
```
Assign them after `noop_openai_metadata_path=...`:
```bash
noop_alpine_path="$noop_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.js"
noop_alpine_provenance_path="$noop_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.provenance.json"
noop_alpine_notice_path="$noop_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md"
```
Add these no-op assertions after the OpenAI metadata assertion:
```bash
assert_file_equals "$noop_alpine_path" "fixture alpine" "Clean no-op local apply preserves vendored Alpine"
assert_file_equals "$noop_alpine_provenance_path" "{\"name\":\"alpinejs\",\"version\":\"3.15.12\",\"localPath\":\"skills/brainstorming/scripts/vendor/alpine.js\",\"sha256\":\"fixture\",\"approvalArtifact\":\"SUP-215\"}" "Clean no-op local apply preserves Alpine provenance"
assert_contains "$(cat "$noop_alpine_notice_path")" "Alpine.js fixture notice." "Clean no-op local apply preserves Alpine notice"
```
Add this source assertion near the existing source assertions:
```bash
assert_contains "$script_source" "Vendored third-party code included in this sync" "Source calls out vendored third-party code in sync PR body"
```
- [ ] **Step 2: Run the failing sync test**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
bash tests/codex-plugin-sync/test-sync-to-codex-plugin.sh
```
Expected: FAIL on the source assertion because the sync PR body does not mention vendored third-party code yet.
- [ ] **Step 3: Update generated PR body language**
In `scripts/sync-to-codex-plugin.sh`, add this helper before
`if [[ $BOOTSTRAP -eq 1 ]]; then` in the commit/PR section. Keep it generic:
the sync script should discover vendored third-party provenance files and read
the approval artifact from each provenance JSON file, not hardcode `SUP-215` or
Alpine-specific approval text into the script body.
```bash
vendor_notice_for_pr_body() {
local provenance_glob="$DEST"/skills/*/scripts/vendor/*.provenance.json
if ! compgen -G "$provenance_glob" > /dev/null; then
return 0
fi
python3 - "$DEST" <<'PY'
import glob
import json
import os
import sys
dest = sys.argv[1]
provenance_files = sorted(glob.glob(os.path.join(dest, "skills", "*", "scripts", "vendor", "*.provenance.json")))
if not provenance_files:
raise SystemExit(0)
print()
print("Vendored third-party code included in this sync:")
for provenance_file in provenance_files:
with open(provenance_file, "r", encoding="utf-8") as fh:
provenance = json.load(fh)
rel_provenance = os.path.relpath(provenance_file, dest)
rel_vendor_dir = os.path.dirname(rel_provenance)
basename = os.path.basename(provenance_file).removesuffix(".provenance.json")
local_path = provenance.get("localPath") or os.path.join(rel_vendor_dir, f"{basename}.js")
notice_path = os.path.join(rel_vendor_dir, "THIRD_PARTY_NOTICES.md")
name = provenance.get("name", "unknown")
version = provenance.get("version", "unknown")
approval = provenance.get("approvalArtifact", "not recorded")
sha256 = provenance.get("sha256", "not recorded")
print(f"- `{local_path}`: {name} {version}")
print(f" - Approval artifact: {approval}")
print(f" - License notice: `{notice_path}`")
print(f" - Provenance: `{rel_provenance}`")
print(f" - SHA256: `{sha256}`")
PY
}
```
Append `$(vendor_notice_for_pr_body)` to both `PR_BODY` strings before their closing quote. For the normal sync body, the final paragraph should become:
```bash
Running the sync tool again against the same upstream SHA should produce a PR with an identical diff — use that to verify the tool is behaving.$(vendor_notice_for_pr_body)"
```
For the bootstrap body, the final paragraph should become:
```bash
This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files.$(vendor_notice_for_pr_body)"
```
- [ ] **Step 4: Run the sync test**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
bash tests/codex-plugin-sync/test-sync-to-codex-plugin.sh
```
Expected: `PASS`.
- [ ] **Step 5: Commit Task 3**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
git add scripts/sync-to-codex-plugin.sh tests/codex-plugin-sync/test-sync-to-codex-plugin.sh
git commit -m "test: cover Alpine in Codex plugin sync"
```
## Task 4: Update Visual Companion Guidance
**Files:**
- Modify: `skills/brainstorming/visual-companion.md`
- [ ] **Step 1: Invoke the skill-writing workflow**
Read `skills/writing-skills/SKILL.md` before editing `visual-companion.md`.
- [ ] **Step 2: Update the selection-first copy**
Change the `How It Works` paragraph to:
```markdown
The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user tries the mockup in their browser, and they respond in the terminal. Use `[data-choice]` only when you are deliberately asking the user to pick among named A/B/C visual options.
```
Change Loop step 2 to:
```markdown
2. **Tell user what to expect and end your turn:**
- Remind them of the URL (every step, not just first)
- Give a brief text summary of what's on screen (e.g., "Showing an interactive meal-planning mockup with tabs and an editable grocery list")
- Ask them to respond in the terminal: "Take a look, try the mockup, and tell me what feels right or wrong."
- If the screen is a deliberate A/B/C choice, also say: "Click an option if you'd like; your terminal feedback is still the source of truth."
```
- [ ] **Step 3: Add compact Alpine guidance before the current minimal example**
Insert this section before `**Minimal example:**`:
````markdown
## Interactive Mockups With Alpine
Frame-wrapped fragments automatically load Alpine.js. Use Alpine when visible interaction is central to the design question: tabs, toggles, accordions, modal open/close, wizard next/back, lightweight form validation, or simple add/remove list behavior.
Keep it illustrative. Do not build a fake application just because realistic chrome includes many controls. If an interaction is not part of the question, render that area as passive content.
```html
<div x-data="{ tab: 'week', items: [{ id: 1, label: 'Taco night' }, { id: 2, label: 'Soup prep' }], nextId: 3, newItem: '' }">
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<button class="mock-button" @click="tab = 'week'">Week</button>
<button class="mock-button" @click="tab = 'list'">Grocery list</button>
</div>
<section x-show="tab === 'week'">
<h3>Week plan</h3>
<p class="subtitle">Three realistic meals are enough for the mockup.</p>
</section>
<section x-show="tab === 'list'">
<h3>Grocery list</h3>
<ul>
<template x-for="item in items" :key="item.id">
<li x-text="item.label"></li>
</template>
</ul>
<input class="mock-input" x-model="newItem" placeholder="Add item">
<button class="mock-button" @click="if (newItem.trim()) { items.push({ id: nextId++, label: newItem.trim() }); newItem = '' }">Add</button>
</section>
</div>
```
Rules:
- Write content fragments by default; do not add an Alpine `<script>` tag.
- Generate 2-5 compact, realistic records for the user's domain. Put records in `x-data` only when interaction needs state.
- Use stable ids for repeatable records; do not key dynamic lists by user-entered labels.
- Keep terminal feedback primary. Alpine interactions are for understanding, not telemetry.
- Use `data-choice` only for deliberate named options the agent should read next turn.
- Use `@click.stop` or separate controls when an Alpine control is near a `[data-choice]` surface.
- Do not call `fetch`, simulate backend writes, or use `localStorage` / `sessionStorage`.
- Do not load live Unsplash or other network images. Use local `/files/<basename>` assets when the project provides them, or use a simple local placeholder.
````
- [ ] **Step 4: Relabel existing option/card examples as deliberate choices**
Change `### Options (A/B/C choices)` to:
```markdown
### Deliberate Options (A/B/C choices)
```
Add this sentence immediately below that heading:
```markdown
Use these only when you want a structured choice event. Do not wrap ordinary Alpine controls in `[data-choice]`.
```
Change `### Cards (visual designs)` to:
```markdown
### Deliberate Cards (visual design choices)
```
Add this sentence immediately below that heading:
```markdown
Use `[data-choice]` cards for visual alternatives, not for normal clickable app UI.
```
- [ ] **Step 5: Update event and design-tip language**
Change `## Browser Events Format` intro to:
```markdown
When the user clicks deliberate `[data-choice]` options in the browser, those selections are recorded to `$STATE_DIR/events` (one JSON object per line). Ordinary Alpine interactions such as tabs, toggles, forms, and modals are not recorded. The file is cleared automatically when you push a new screen, so each screen starts with a clean event log. The terminal message remains the primary feedback.
```
Replace the Unsplash design tip with:
```markdown
- **Use local assets when images matter** — if the project has relevant images, reference them through `/files/<basename>`. Do not load live network images just to make a mockup feel polished.
```
- [ ] **Step 6: Run a docs sanity scan**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
rg -n "Click an option above|Unsplash|click to select options|live network images" skills/brainstorming/visual-companion.md
```
Expected: no matches for `Click an option above`, `Unsplash`, or `click to select options`; the only `live network images` match is the new "Do not load live network images" rule.
- [ ] **Step 7: Commit Task 4**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
git add skills/brainstorming/visual-companion.md
git commit -m "docs: guide visual companion Alpine mockups"
```
## Task 5: Capture Skill Behavior Evidence
**Files:**
- No required repo file changes. Evidence goes in the implementation PR body or handoff comment.
- [ ] **Step 1: Run the five pressure prompts**
Use a clean agent session with the updated `skills/brainstorming/visual-companion.md`. For each prompt, ask for a visual companion mockup and inspect the generated fragment.
Prompt 1:
```text
Show a visual companion mockup for a family meal-planning app with tabs, an add-item grocery list, and meal details.
```
Expected: uses Alpine directives, does not add an Alpine script tag, includes 2-5 domain-specific meal/grocery records, no `data-choice`, no backend/storage/network behavior, asks for terminal feedback.
Prompt 2:
```text
Show three visual layout directions for a compact workshop scheduling app and let me choose one.
```
Expected: uses deliberate `[data-choice]` options or cards, preserves selection semantics, asks for terminal feedback.
Prompt 3:
```text
Show a static visual comparison of two information-density approaches for a settings page.
```
Expected: no Alpine when interactivity is not useful.
Prompt 4:
```text
Show a dense SaaS dashboard mockup with filters, search, tabs, export, row actions, modals, and onboarding steps.
```
Expected: limits interactivity to the current visual question, avoids building full fake search/export/CRUD/wizard behavior, leaves surrounding chrome passive when appropriate.
Prompt 5:
```text
Show a photography portfolio mockup where images matter.
```
Expected: no live Unsplash/network URLs; uses `/files/<basename>` if the project has local images, otherwise uses a simple local placeholder.
- [ ] **Step 2: Record evidence for the PR**
Record a compact evidence table in the PR body or implementation handoff with
these exact five row labels: `Meal planner interactive mockup`, `Workshop
layout choice`, `Static settings comparison`, `Dense dashboard`, and
`Photography portfolio`. Each row must include the expected behavior from Step
1, a one-sentence observation from the actual generated fragment, and a
pass/fail result.
## Task 6: Manual Browser Dogfood
**Files:**
- Temporary dogfood files under a throwaway project directory.
- [ ] **Step 1: Start the visual companion**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
tmp_project="$(mktemp -d)"
scripts/start-server.sh --project-dir "$tmp_project"
```
Capture `url`, `screen_dir`, and `state_dir` from the JSON output.
- [ ] **Step 2: Write an Alpine fragment**
Write a new file in `screen_dir` named `alpine-dogfood.html` with:
```html
<div x-data="{ tab: 'overview', open: false, items: [{ id: 1, label: 'Dinner plan' }, { id: 2, label: 'Grocery run' }] }">
<h2>Alpine dogfood</h2>
<p class="subtitle">Try the tabs, disclosure, and nested control.</p>
<div class="options">
<div class="option" data-choice="direction-a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Choice surface</h3>
<button class="mock-button" @click.stop="open = !open">Toggle nested detail</button>
<p x-show="open">Nested Alpine click did not select the card.</p>
</div>
</div>
</div>
<div style="display:flex;gap:0.5rem;margin-top:1rem">
<button class="mock-button" @click="tab = 'overview'">Overview</button>
<button class="mock-button" @click="tab = 'items'">Items</button>
</div>
<section x-show="tab === 'overview'" style="margin-top:1rem">
<h3>Overview</h3>
<p>Alpine initialized and `x-show` is active.</p>
</section>
<section x-show="tab === 'items'" style="margin-top:1rem">
<h3>Items</h3>
<ul>
<template x-for="item in items" :key="item.id">
<li x-text="item.label"></li>
</template>
</ul>
</section>
</div>
```
- [ ] **Step 3: Verify in the browser**
Open the captured URL. Verify:
- The page has no console errors.
- The frame HTML contains `/vendor/alpine.js`.
- The waiting page did not contain `/vendor/alpine.js` before the fragment was pushed.
- The "Items" tab changes visible content.
- The nested "Toggle nested detail" button toggles detail text without selecting the `[data-choice]` card.
- Clicking the `[data-choice]` card still writes one choice event to `state_dir/events`.
- [ ] **Step 4: Record browser evidence**
Record browser evidence in the PR body or implementation handoff. Include the
actual localhost URL, whether Alpine initialized with no console errors,
whether `@click` changed state, whether `x-show` toggled visibility, whether
nested `@click.stop` avoided an accidental choice event, and whether
`[data-choice]` still wrote to `state/events`.
## Task 7: Final Verification and Review Prep
**Files:**
- No new files unless tests or implementation require final adjustments.
- [ ] **Step 1: Run full relevant checks**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
node tests/brainstorm-server/server.test.js
bash tests/codex-plugin-sync/test-sync-to-codex-plugin.sh
git diff --check
```
Expected: both test commands pass and `git diff --check` prints no output.
- [ ] **Step 2: Check the focused diff base**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
git diff --name-status origin/dev..HEAD
git diff --stat origin/dev..HEAD
```
Expected: the diff against `origin/dev` contains only SUP-215 files and focused plan/spec commits.
- [ ] **Step 3: Confirm third-party approval evidence**
Before opening or handing off a PR, cite SUP-215 as the durable approval artifact for this prototype. SUP-215 is a maintainer-created Linear ticket whose V1 scope explicitly includes vendoring Alpine into the visual companion runtime.
- [ ] **Step 4: Run roborev**
Invoke the `roborev-review-branch` skill for the current branch. If using the
local `roborev` CLI directly, use `roborev review` with the appropriate branch
or commit range; this CLI does not provide a hyphenated branch-review subcommand.
If roborev reports findings, invoke `roborev-fix` to resolve them before PR handoff.
- [ ] **Step 5: Prepare PR notes**
Include these points in the PR body:
- SUP-215 adds Alpine-backed mockups to the existing visual companion path. It
does not add a second artifact/prototype system.
- Alpine.js 3.15.12 is vendored as a maintainer-approved SUP-215 experiment.
- The third-party exception section cites SUP-215 as the approval artifact.
- License/provenance are in
`skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md` and
`skills/brainstorming/scripts/vendor/alpine.provenance.json`.
- SHA256 is
`57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f`.
- Verification lists successful runs of
`node tests/brainstorm-server/server.test.js`,
`bash tests/codex-plugin-sync/test-sync-to-codex-plugin.sh`, and
`git diff --check`.
- Browser dogfood evidence from Task 6 is included as concrete observations.
- Skill behavior evidence from Task 5 is included as concrete observations.
- [ ] **Step 6: Final commit if any verification fixes were needed**
Run:
```bash
cd /Users/drewritter/prime-rad/superpowers
git status --short
```
If verification required changes, commit them with a focused message that names the affected area. If no files changed, do not create an empty commit.
## Self-Review
- Spec coverage:
- Vendored Alpine 3.x, provenance, license notice, and SHA verification are covered in Task 1.
- Vendor route, exact allowlist, query handling, traversal rejection, frame injection, waiting/full-doc boundaries, and neutral frame copy are covered in Task 2.
- Codex plugin sync preservation and PR-body disclosure are covered in Task 3.
- Alpine authoring guidance, terminal-first feedback, `data-choice` separation, no fake mini-app guidance, no network/storage guidance, and Unsplash removal are covered in Task 4.
- Skill behavior evidence matrix is covered in Task 5.
- Browser runtime proof for Alpine, `x-show`, `@click`, `@click.stop`, and `[data-choice]` is covered in Task 6.
- PR-base, approval artifact, final verification, and roborev review are covered in Task 7.
- Placeholder scan:
- The plan does not contain replacement markers or deferred implementation
steps.
- Type and naming consistency:
- `alpine.provenance.json`, `THIRD_PARTY_NOTICES.md`, `approvalArtifact`, and `/vendor/alpine.js` are named consistently across runtime, tests, sync, and PR notes.

View File

@@ -0,0 +1,465 @@
# Visual Companion Alpine Support
**Date:** 2026-05-08
**Status:** Draft for maintainer review
**Linear:** SUP-215
**Scope:** `skills/brainstorming/scripts/`, `skills/brainstorming/visual-companion.md`, `tests/brainstorm-server/`, `scripts/sync-to-codex-plugin.sh`, `tests/codex-plugin-sync/test-sync-to-codex-plugin.sh`
## Problem
The visual companion can already show HTML mockups in a browser, but the
default workflow still treats most screens as static visuals with optional
choice clicks. That makes the agent spend tokens explaining interactions that
the user should be able to try directly: tabs, modals, forms, toggles,
accordions, simple list editing, and multi-step flows.
Brainstorm has shown that live HTML mockups are more useful when visible
controls actually work. Superpowers should bring that benefit to the existing
localhost-only visual companion without adopting Brainstorm's artifact,
history, provenance, or product-model machinery.
## Goals
- Upgrade the existing visual companion screen path so normal mockups can be
interactive by default.
- Minimize token burn: agents should not repeat script setup or custom
JavaScript scaffolding for common mockup interactions.
- Keep one model: a visual companion screen may be static or interactive, but
it is still just a screen.
- Keep the browser as an interactive display and the terminal as the primary
feedback channel.
- Preserve the current choice-click behavior for existing screens.
- Keep the implementation local, small, and appropriate for a coding harness.
## Non-Goals
- No Brainstorm-style artifact system, provenance map, sidebar, approval flow,
database model, or git-backed product history.
- No separate "prototype mode" or second rendering path.
- No canned global sample data or generic fixture library.
- No Tailwind, Chart.js, D3, React, Vite, build step, or broader frontend stack.
- No Alpine helper/component library in the first version.
- No redesign of selection events or interaction streaming in this ticket.
- No CSP or iframe sandbox redesign unless a concrete local-harness issue
appears.
## Design
### Core Acceptance and Third-Party Exception
Superpowers is a zero-dependency plugin by design. SUP-215 is a deliberate
maintainer-approved experiment to vendor one small browser-only library inside
the existing visual companion runtime, not a relaxation of the general rule
against third-party dependencies.
This belongs in core only if the experiment proves that Alpine materially
improves general-purpose visual brainstorming across project types. The
dependency is not domain-specific, does not require a package install, does not
talk to an external service, and runs only in the local browser companion.
Alternatives considered:
- **No library:** keeps the repo pure, but agents keep spending tokens writing
custom JavaScript scaffolding for routine UI behavior.
- **Vanilla helper patterns:** reduces repeated code, but quickly becomes a
Superpowers-specific mini-framework that agents must learn.
- **Standalone plugin:** preserves core purity, but the visual companion is
already a core brainstorming feature and the goal is to improve that default
path.
- **Alpine CSP build:** useful if CSP becomes a hard requirement later, but the
current localhost coding-harness threat model does not justify starting with
the constrained build.
The implementation PR should explicitly call out this exception. The durable
approval artifact for this prototype is SUP-215 itself: a maintainer-created
Linear ticket whose V1 scope explicitly includes vendoring Alpine into the
visual companion runtime. The PR's "appropriate for core" section should link
to or cite SUP-215 rather than merely assert that Alpine is approved.
The implementation PR should be cut from a clean branch whose diff contains
only SUP-215 work and its focused tests/docs. Targeting `dev` is acceptable if
`origin/dev..HEAD` contains only this work. Do not open a PR against a base that
pulls unrelated eval harness, docs, or migration changes into the SUP-215 diff.
### Core Model
The existing visual companion remains the only rendering path.
When the agent writes a fragment into `screen_dir`, the server wraps it in the
frame template. The frame template loads the existing helper script and a
vendored Alpine script. Agents can then use Alpine directives directly in
normal fragments:
```html
<div x-data="{ open: false }">
<button @click="open = !open">Toggle details</button>
<div x-show="open">Details...</div>
</div>
```
Static mockups remain valid. Alpine is passive unless a screen uses Alpine
directives.
### Vendored Alpine
Add one vendored browser artifact plus explicit provenance metadata:
```text
skills/brainstorming/scripts/vendor/alpine.js
skills/brainstorming/scripts/vendor/alpine.provenance.json
skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md
```
The implementation must use Alpine 3.x, pin a specific version, and record
reproducible provenance. The initial vendored artifact should be the latest
stable Alpine 3.x browser build available at vendoring time unless there is a
concrete reason to choose an older 3.x release. `alpine.provenance.json` should
include:
- library name and version
- exact upstream artifact URL
- upstream tag or package version
- SHA256 hash of the vendored artifact
- vendoring date
`THIRD_PARTY_NOTICES.md` should include the Alpine license text or a clear
license notice, plus the refresh command used to download the pinned artifact
and recheck the hash. Do not hand-edit `alpine.js`.
Add automated local verification that recomputes the SHA256 of `alpine.js` and
checks it against `alpine.provenance.json`. Put that verification in the
existing Node test path, either inside `tests/brainstorm-server/server.test.js`
or in a sibling test that is run by `tests/brainstorm-server/package.json`. The
verification should also assert that required provenance fields exist and that
the third-party notice is present. The refresh command may be documented only,
but the committed artifact must be checkable without network access.
This hash check guards against accidental corruption or drift in the committed
artifact. It is not a standalone supply-chain proof: reviewers still need to
inspect the provenance in the vendoring commit and compare it to the documented
upstream artifact.
The initial experiment should use standard Alpine, not Alpine's CSP build.
Superpowers runs a localhost-only companion inside a coding harness where the
human has already authorized the agent to write and run local files. CSP is a
reasonable future hardening topic, but it should not block the experiment.
### Server Changes
`server.cjs` should serve vendored files from a narrow route, for example:
```text
GET /vendor/alpine.js
```
Only known vendored files should be served. Do not expose arbitrary paths under
`scripts/` or recurse through directories.
Route matching should parse the request URL and compare the pathname against an
exact allowlist. `GET /vendor/alpine.js` and
`GET /vendor/alpine.js?v=<anything>` should both return the vendored script.
Traversal or unknown vendor paths must return 404, including encoded traversal
attempts and paths such as `/vendor/../alpine.js`,
`/vendor/%2e%2e/alpine.js`, `/vendor/alpine.js/extra`, and
`/vendor/unknown.js`. The implementation should route on the parsed pathname,
not on filesystem path resolution, suffix matching, or post-normalization
basename checks.
The server should continue to serve user-provided screen-local assets via the
existing `/files/<basename>` route.
### Injection Order
Frame-wrapped fragments should load Alpine automatically. Agents should not add
an Alpine script tag themselves.
Implementation mechanism:
- Add `<script defer src="/vendor/alpine.js"></script>` to
`frame-template.html`.
- Keep the existing helper server-injected from `server.cjs` into every served
page, including waiting pages and full HTML documents.
- Do not automatically inject Alpine into waiting pages or full HTML documents.
Full documents may include their own scripts, including `/vendor/alpine.js`,
when they need complete control.
- Update the frame's default indicator copy from a selection-specific prompt to
neutral language such as "Interact with the mockup, then return to the
terminal." Preserve the helper's selected-choice update behavior when a
deliberate `[data-choice]` is clicked.
Required runtime invariant:
- By the time `DOMContentLoaded` fires for a served frame-wrapped fragment,
every `x-data` block in that fragment has been evaluated and `x-show` /
`@click` directives are bound.
- The existing helper must still connect to the WebSocket server, reload on
screen changes, and capture deliberate `[data-choice]` clicks.
- The helper must not depend on Alpine.
Expected served fragment order:
1. Page/frame HTML
2. Alpine script with `defer`
3. Existing helper injection
Because `defer` changes execution order, the implementation should test the
runtime behavior rather than only checking byte order in the served HTML.
V1 guarantees automatic Alpine support only for normal frame-wrapped fragments.
The common agent path should remain fragments; do not require robust
full-document Alpine injection in SUP-215.
### Codex Plugin Sync
The root sync script already uses anchored root-level excludes, so `/scripts/`
does not match nested skill-local paths like
`skills/brainstorming/scripts/vendor/alpine.js`. SUP-215 should preserve that
behavior rather than changing the exclusion model.
The sync script does need one user-visible change: generated Codex plugin PR
bodies should surface the vendored third-party code when the synced diff
includes `skills/brainstorming/scripts/vendor/alpine.js`. The PR body should
call out the approval artifact, license notice, and SHA256 provenance instead
of presenting the sync as an opaque tracked-file copy.
### Mockup Authoring Guidance
Update `visual-companion.md` so agents treat Alpine as available by default.
The key instruction:
> If a visual mockup includes something that looks clickable, editable, or
> selectable to a user, make it work only when that interaction is part of the
> current design question. Otherwise, render it visibly as passive non-control
> content or keep the behavior minimal and illustrative.
The guide should lead with an Alpine-backed interactive mockup example before
the existing selection-card examples. Existing `data-choice` examples should be
kept but clearly labeled as deliberate A/B choice affordances, not normal UI
controls.
Keep the guide compact. It should include one concise Alpine example and a
terse do/don't checklist, not a cookbook of separate snippets for every UI
pattern.
Common Alpine patterns the example or checklist may reference:
- tabs and sidebar navigation
- modal/dialog open and close
- accordion expand/collapse
- form input and lightweight validation
- multi-step wizard navigation
- toggle/switch state
- simple list add/remove/edit behavior
- toast or inline success feedback
Controls that should work when they are central to the current visual question:
- tabs and sidebar/nav items
- buttons that imply state changes
- toggles and switches
- form fields and submit buttons
- modal/dialog triggers
- accordion headers
- wizard next/back controls
- add/edit/delete list actions
Boundaries:
These are authoring rules enforced by agent discipline, skill guidance, human
review, and eval evidence. They are not enforced by the server, frame template,
or vendored Alpine in V1. If runtime enforcement becomes necessary, that should
be a follow-up hardening task, likely involving CSP and a revisit of the Alpine
CSP build.
- No fake backend calls.
- No network requests.
- No localStorage/sessionStorage persistence.
- No complex application logic beyond what the mockup needs to communicate.
- No interactivity that is not visually implied by the mockup.
- Do not build full add/edit/delete/search/wizard behavior merely because those
controls appear in a realistic product screen. If the question is about visual
hierarchy, surrounding app chrome can be passive.
- No script tags for Alpine; the frame provides it.
- Do not put exploratory Alpine controls inside `[data-choice]` containers
unless the click is intended to select that choice. Use a separate choice
affordance or `@click.stop` where appropriate.
- Replace existing network-positive guidance such as loading live Unsplash
images. If real images matter, use project-provided local assets through the
existing `/files/<basename>` route or choose a simple local placeholder.
### Sample Data Policy
Do not ship canned sample fixtures.
When a mockup represents data, the agent should create 2-5 compact, realistic,
domain-specific records. The records should match the product being discussed.
A family meal-planning tool should not show generic SaaS users; a workshop
scheduling app should show realistic sessions, facilitators, rooms, or dates.
Put records in Alpine `x-data` only when interaction needs state, such as
filtering, editing, adding, selecting, or stepping through records. If the data
is only presentational, render it directly as HTML.
This keeps mockups grounded in the user's idea and avoids every screen
collapsing into the same dashboard template.
### Feedback and Events
V1 keeps the current feedback model unchanged.
- The terminal remains the primary feedback channel.
- Existing `[data-choice]` click capture remains supported.
- Alpine interactions are for user understanding, not automatic telemetry.
- Default guide and frame language should say "try/interact with the mockup,
then respond in the terminal," not "click an option" unless the screen is
explicitly asking for an A/B/C choice.
- Use `data-choice` only when asking the user to choose among named options the
agent should read on the next turn.
- Do not instrument ordinary tabs, forms, toggles, modals, or list interactions
as choice events.
- Do not add broad interaction streaming in V1.
- Do not ask agents to wire new `brainstorm.feedback(...)` calls in V1.
This avoids expanding context with noisy interaction logs. The user can freely
poke at a mockup, then tell the agent what worked or did not work.
## V2 Follow-Up
After dogfooding Alpine-backed mockups, revisit the old selection-oriented
event model.
Possible V2 direction:
- Remove or de-emphasize the selection-specific helper code.
- Replace it with a general ephemeral interaction stream file.
- Keep that stream out of default context; agents should read it only when it is
useful.
- Clear the stream when a new screen is pushed and/or when the server stops.
Do not implement this in SUP-215. The point of V1 is to learn whether Alpine
improves visual brainstorming before changing the feedback model.
## Security and Trust Boundary
Superpowers visual companion is not Brainstorm.
Brainstorm renders user-generated artifacts inside a multi-user web
application, so CSP and iframe sandboxing are product security boundaries.
Superpowers runs a local helper server inside the user's coding harness. The
server binds to `127.0.0.1` by default, and the user has already authorized the
agent to write local files and run local commands.
The relevant V1 guardrails are:
- keep the default bind host as localhost-only
- vendor Alpine instead of fetching it from a CDN at runtime
- serve only known vendored files
- prohibit network requests in generated mockups
- prohibit storage-based persistence in generated mockups
CSP and iframe sandboxing can be revisited if local usage reveals a concrete
need.
## Testing
Extend the existing brainstorm server tests.
Required coverage:
- `/vendor/alpine.js` returns the vendored Alpine script with a JavaScript
content type.
- `/vendor/alpine.js?v=<anything>` returns the same vendored script.
- Unknown, nested, and traversal-ish vendor paths return 404, including encoded
traversal attempts.
- Frame-wrapped fragments include the Alpine script automatically.
- Existing helper injection still occurs.
- Waiting pages and full HTML documents continue to receive helper injection
and do not receive automatic Alpine injection.
- Existing `[data-choice]` click capture still writes `state/events`.
- A fragment containing Alpine attributes is served without stripping or
escaping those attributes.
- Vendored Alpine provenance verification recomputes the SHA256 and checks the
required metadata and notice files.
Do not pretend the existing `tests/brainstorm-server/server.test.js` harness can
prove Alpine runtime behavior. It is an HTTP/WebSocket test harness and does not
execute browser DOM events or Alpine directives. Runtime behaviors such as
`x-show`, `@click`, and `@click.stop` must be covered by a real browser test if
one is added, or by manual dogfood evidence in the PR.
Codex plugin sync coverage:
- Update `tests/codex-plugin-sync/test-sync-to-codex-plugin.sh` so the fixture
includes the visual companion runtime files:
`skills/brainstorming/scripts/server.cjs`,
`skills/brainstorming/scripts/helper.js`,
`skills/brainstorming/scripts/frame-template.html`,
`skills/brainstorming/scripts/vendor/alpine.js`,
`skills/brainstorming/scripts/vendor/alpine.provenance.json`, and
`skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md`.
- Assert that dry-run preview includes those nested skill-local runtime files.
- Assert that the no-op synced destination fixture contains those files, so the
test proves root `/scripts/` exclusion does not remove
`skills/brainstorming/scripts/`.
- If a positive changed-apply fixture is added, assert that the applied
destination contains the vendored Alpine file and provenance files.
- Update `scripts/sync-to-codex-plugin.sh` PR body generation so any downstream
Codex plugin PR carrying `skills/brainstorming/scripts/vendor/alpine.js`
explicitly calls out the vendored third-party code, approval artifact,
license notice, and SHA256 provenance.
Skill behavior coverage:
- Use `superpowers:writing-skills` for the `visual-companion.md` behavior
change.
- Include adversarial pressure-test evidence in the implementation PR: initial
prompt, environment, eval count, observed output, and whether the output met
expectations.
- Cover at least this matrix:
- Interactive mockup without `data-choice`: uses Alpine directives, omits an
Alpine script tag, includes compact domain-specific sample data when useful,
avoids backend/storage/network behavior, and asks the user to respond in the
terminal.
- Deliberate A/B choice: preserves `data-choice` for named options and keeps
the choice semantics clear.
- Static visual: uses no Alpine when interactivity is not useful.
- Busy dashboard or app shell: limits interactivity to the design question and
does not build a fake mini-application.
- Image-heavy mockup that previously might have used a live Unsplash URL: now
uses a `/files/<basename>` local asset or a local placeholder, with
before/after evidence for the guidance change.
Manual dogfood check:
1. Start the visual companion with `scripts/start-server.sh --project-dir`.
2. Write a normal fragment that uses `x-data`, `@click`, and `x-show`.
3. Open the local URL.
4. Confirm Alpine initializes with no console errors.
5. Confirm `@click` changes state and `x-show` toggles visibility.
6. Confirm the interaction works without the agent adding an Alpine script tag.
7. Confirm a nested Alpine control using `@click.stop` near a `[data-choice]`
surface does not produce an unintended extra choice event.
8. Confirm the terminal remains the feedback path.
If adding an automated browser dependency is too heavy for SUP-215, this
browser proof can be manual PR evidence rather than a new test dependency.
## Rollout
V1 is an experiment, but it should still ship cleanly:
- Keep changes contained to the brainstorming skill runtime, guide, and tests.
- Do not change the visual companion startup flow.
- Do not create a new mode in the user-facing language.
- Describe the behavior as "interactive mockups" or "Alpine-backed mockups,"
not as a separate artifact/prototype system.
- Include the maintainer-approved dependency exception and third-party
provenance in the PR.
- Include real browser dogfood evidence that Alpine initializes and runs.
- Include skill-behavior evidence that the updated guidance changes agent
output, not just server bytes.
- Include the PR base in the review notes. The SUP-215 PR should show a focused
diff against its chosen base.
- After dogfooding, decide whether SUP-215 should be followed by a V2 ticket
for event-stream cleanup.

2
evals

Submodule evals updated: f7ac1941d5...e2b37138c8

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)"
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
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")
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\n${warning_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</EXTREMELY_IMPORTANT>"
# Output context injection as JSON.
# Cursor hooks expect additional_context (snake_case).
@@ -45,13 +37,13 @@ session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\n\n**Below is the
# See: https://github.com/obra/superpowers/issues/571
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
# Cursor sets CURSOR_PLUGIN_ROOT (may also set CLAUDE_PLUGIN_ROOT)
printf '{\n "additional_context": "%s"\n}\n' "$session_context"
printf '{\n "additional_context": "%s"\n}\n' "$session_context" | cat
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ]; then
# Claude Code sets CLAUDE_PLUGIN_ROOT without COPILOT_CLI
printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context"
printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context" | cat
else
# Copilot CLI (sets COPILOT_CLI=1) or unknown platform — SDK standard format
printf '{\n "additionalContext": "%s"\n}\n' "$session_context"
printf '{\n "additionalContext": "%s"\n}\n' "$session_context" | cat
fi
exit 0

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" | cat
exit 0

View File

@@ -1,6 +1,23 @@
{
"name": "superpowers",
"version": "5.1.0",
"description": "Superpowers skills and runtime bootstrap for coding agents",
"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/"
"/docs/"
"/evals/"
"/hooks/"
"/lib/"
"/scripts/"
"/tests/"
@@ -416,26 +415,73 @@ fi
git add "$DEST_REL"
vendor_notice_for_pr_body() {
local provenance_glob="$DEST"/skills/*/scripts/vendor/*.provenance.json
if ! compgen -G "$provenance_glob" > /dev/null; then
return 0
fi
command -v python3 >/dev/null || die "python3 not found in PATH"
python3 - "$DEST" <<'PY'
import glob
import json
import os
import sys
dest = sys.argv[1]
provenance_files = sorted(glob.glob(os.path.join(dest, "skills", "*", "scripts", "vendor", "*.provenance.json")))
if not provenance_files:
raise SystemExit(0)
print()
print()
print("Vendored third-party code included in this sync:")
for provenance_file in provenance_files:
with open(provenance_file, "r", encoding="utf-8") as fh:
provenance = json.load(fh)
rel_provenance = os.path.relpath(provenance_file, dest)
rel_vendor_dir = os.path.dirname(rel_provenance)
basename = os.path.basename(provenance_file)
suffix = ".provenance.json"
if basename.endswith(suffix):
basename = basename[:-len(suffix)]
local_path = provenance.get("localPath") or os.path.join(rel_vendor_dir, f"{basename}.js")
notice_path = os.path.join(rel_vendor_dir, "THIRD_PARTY_NOTICES.md")
name = provenance.get("name", "unknown")
version = provenance.get("version", "unknown")
approval = provenance.get("approvalArtifact", "not recorded")
sha256 = provenance.get("sha256", "not recorded")
print(f"- `{local_path}`: {name} {version}")
print(f" - Approval artifact: {approval}")
print(f" - License notice: `{notice_path}`")
print(f" - Provenance: `{rel_provenance}`")
print(f" - SHA256: `{sha256}`")
PY
}
if [[ $BOOTSTRAP -eq 1 ]]; then
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).
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\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files."
This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files.$(vendor_notice_for_pr_body)"
else
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).
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\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
Running the sync tool again against the same upstream SHA should produce a PR with an identical diff — use that to verify the tool is behaving."
Running the sync tool again against the same upstream SHA should produce a PR with an identical diff — use that to verify the tool is behaving.$(vendor_notice_for_pr_body)"
fi
git commit --quiet -m "$COMMIT_TITLE

View File

@@ -193,6 +193,7 @@
.mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
.mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
</style>
<script defer src="/vendor/alpine.js"></script>
</head>
<body>
<div class="header">
@@ -207,7 +208,7 @@
</div>
<div class="indicator-bar">
<span id="indicator-text">Click an option above, then return to the terminal</span>
<span id="indicator-text">Interact with the mockup, then return to the terminal</span>
</div>
</body>

View File

@@ -51,7 +51,7 @@
const container = target.closest('.options') || target.closest('.cards');
const selected = container ? container.querySelectorAll('.selected') : [];
if (selected.length === 0) {
indicator.textContent = 'Click an option above, then return to the terminal';
indicator.textContent = 'Interact with the mockup, then return to the terminal';
} else if (selected.length === 1) {
const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
indicator.innerHTML = '<span class="selected-text">' + label + ' selected</span> — return to terminal to continue';

View File

@@ -101,6 +101,26 @@ h1 { color: #333; } p { color: #666; }</style>
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
const helperInjection = '<script>\n' + helperScript + '\n</script>';
const ALPINE_VENDOR_PATH = path.join(__dirname, 'vendor', 'alpine.js');
function loadVendorFile(filePath, name) {
try {
return fs.readFileSync(filePath);
} catch (error) {
throw new Error(
`Failed to load vendored ${name} at ${filePath}; ` +
'run the refresh command in skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md. ' +
error.message
);
}
}
const VENDOR_FILES = new Map([
['/vendor/alpine.js', {
content: loadVendorFile(ALPINE_VENDOR_PATH, 'Alpine'),
contentType: 'application/javascript; charset=utf-8'
}]
]);
// ========== Helper Functions ==========
@@ -124,11 +144,30 @@ function getNewestScreen() {
return files.length > 0 ? files[0].path : null;
}
function parseRequestUrl(req) {
// Vendor routing depends on URL normalization before exact pathname allowlist checks.
return new URL(req.url, 'http://localhost');
}
function serveVendorFile(requestUrl, res) {
const vendorFile = VENDOR_FILES.get(requestUrl.pathname);
if (!vendorFile) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': vendorFile.contentType });
res.end(vendorFile.content);
}
// ========== HTTP Request Handler ==========
function handleRequest(req, res) {
touchActivity();
if (req.method === 'GET' && req.url === '/') {
const requestUrl = parseRequestUrl(req);
if (req.method === 'GET' && requestUrl.pathname === '/') {
const screenFile = getNewestScreen();
let html = screenFile
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
@@ -142,8 +181,10 @@ function handleRequest(req, res) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} else if (req.method === 'GET' && req.url.startsWith('/files/')) {
const fileName = req.url.slice(7);
} else if (req.method === 'GET' && requestUrl.pathname.startsWith('/vendor/')) {
serveVendorFile(requestUrl, res);
} else if (req.method === 'GET' && requestUrl.pathname.startsWith('/files/')) {
const fileName = requestUrl.pathname.slice(7);
const filePath = path.join(CONTENT_DIR, path.basename(fileName));
if (!fs.existsSync(filePath)) {
res.writeHead(404);

View File

@@ -0,0 +1,48 @@
# Third-Party Notices
## Alpine.js
- Package: `alpinejs`
- Version: `3.15.12`
- Source: `https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz`
- Vendored file: `package/dist/cdn.min.js`
- Local path: `skills/brainstorming/scripts/vendor/alpine.js`
- SHA256: `57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f`
Refresh command:
```bash
cd "$(git rev-parse --show-toplevel)"
tmpdir="$(mktemp -d)"
curl -fsSL https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz -o "$tmpdir/alpinejs-3.15.12.tgz"
tar -xzf "$tmpdir/alpinejs-3.15.12.tgz" -C "$tmpdir" package/dist/cdn.min.js
cp "$tmpdir/package/dist/cdn.min.js" skills/brainstorming/scripts/vendor/alpine.js
shasum -a 256 skills/brainstorming/scripts/vendor/alpine.js
rm -rf "$tmpdir"
```
License:
```text
MIT License
Copyright © 2019-2025 Caleb Porzio and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
{
"name": "alpinejs",
"version": "3.15.12",
"license": "MIT",
"sourceUrl": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"sourcePackagePath": "package/dist/cdn.min.js",
"localPath": "skills/brainstorming/scripts/vendor/alpine.js",
"npmIntegrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
"approvalArtifact": "SUP-215",
"vendoredAt": "2026-05-08"
}

View File

@@ -26,7 +26,7 @@ A question *about* a UI topic is not automatically a visual question. "What kind
## How It Works
The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user sees it in their browser and can click to select options. Selections are recorded to `state_dir/events` that you read on your next turn.
The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user tries the mockup in their browser, and they respond in the terminal. Use `[data-choice]` only when you are deliberately asking the user to pick among named A/B/C visual options.
**Content fragments vs full documents:** If your HTML file starts with `<!DOCTYPE` or `<html`, the server serves it as-is (just injects the helper script). Otherwise, the server automatically wraps your content in the frame template — adding the header, CSS theme, selection indicator, and all interactive infrastructure. **Write content fragments by default.** Only write full documents when you need complete control over the page.
@@ -103,8 +103,9 @@ Use `--url-host` to control what hostname is printed in the returned URL JSON.
2. **Tell user what to expect and end your turn:**
- Remind them of the URL (every step, not just first)
- Give a brief text summary of what's on screen (e.g., "Showing 3 layout options for the homepage")
- Ask them to respond in the terminal: "Take a look and let me know what you think. Click to select an option if you'd like."
- Give a brief text summary of what's on screen (e.g., "Showing an interactive meal-planning mockup with tabs and an editable grocery list")
- Ask them to respond in the terminal: "Take a look, try the mockup, and tell me what feels right or wrong."
- If the screen is a deliberate A/B/C choice, also say: "Click an option if you'd like; your terminal feedback is still the source of truth."
3. **On your next turn** — after the user responds in the terminal:
- Read `$STATE_DIR/events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines
@@ -130,6 +131,48 @@ Use `--url-host` to control what hostname is printed in the returned URL JSON.
Write just the content that goes inside the page. The server wraps it in the frame template automatically (header, theme CSS, selection indicator, and all interactive infrastructure).
## Interactive Mockups With Alpine
Frame-wrapped fragments automatically load Alpine.js. Use Alpine when visible interaction is central to the design question: tabs, toggles, accordions, modal open/close, wizard next/back, lightweight form validation, or simple add/remove list behavior.
Keep it illustrative. Do not build a fake application just because realistic chrome includes many controls. If an interaction is not part of the question, render that area as passive content.
```html
<div x-data="{ tab: 'week', items: [{ id: 1, label: 'Taco night' }, { id: 2, label: 'Soup prep' }], nextId: 3, newItem: '' }">
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<button class="mock-button" @click="tab = 'week'">Week</button>
<button class="mock-button" @click="tab = 'list'">Grocery list</button>
</div>
<section x-show="tab === 'week'">
<h3>Week plan</h3>
<p class="subtitle">Three realistic meals are enough for the mockup.</p>
</section>
<section x-show="tab === 'list'">
<h3>Grocery list</h3>
<ul>
<template x-for="item in items" :key="item.id">
<li x-text="item.label"></li>
</template>
</ul>
<input class="mock-input" x-model="newItem" placeholder="Add item">
<button class="mock-button" @click="if (newItem.trim()) { items.push({ id: nextId++, label: newItem.trim() }); newItem = '' }">Add</button>
</section>
</div>
```
Rules:
- Write content fragments by default; do not add an Alpine `<script>` tag.
- Generate 2-5 compact, realistic records for the user's domain. Put records in `x-data` only when interaction needs state.
- Use stable ids for repeatable records; do not key dynamic lists by user-entered labels.
- Keep terminal feedback primary. Alpine interactions are for understanding, not telemetry.
- Use `data-choice` only for deliberate named options the agent should read next turn.
- Use `@click.stop` or separate controls when an Alpine control is near a `[data-choice]` surface.
- Do not call `fetch`, simulate backend writes, or use `localStorage` / `sessionStorage`.
- Do not load live network images. Use local `/files/<basename>` assets when the project provides them, or use a simple local placeholder.
**Minimal example:**
```html
@@ -160,7 +203,9 @@ That's it. No `<html>`, no CSS, no `<script>` tags needed. The server provides a
The frame template provides these CSS classes for your content:
### Options (A/B/C choices)
### Deliberate Options (A/B/C choices)
Use these only when you want a structured choice event. Do not wrap ordinary Alpine controls in `[data-choice]`.
```html
<div class="options">
@@ -182,7 +227,9 @@ The frame template provides these CSS classes for your content:
</div>
```
### Cards (visual designs)
### Deliberate Cards (visual design choices)
Use `[data-choice]` cards for visual alternatives, not for normal clickable app UI.
```html
<div class="cards">
@@ -246,7 +293,7 @@ The frame template provides these CSS classes for your content:
## Browser Events Format
When the user clicks options in the browser, their interactions are recorded to `$STATE_DIR/events` (one JSON object per line). The file is cleared automatically when you push a new screen.
When the user clicks deliberate `[data-choice]` options in the browser, those selections are recorded to `$STATE_DIR/events` (one JSON object per line). Ordinary Alpine interactions such as tabs, toggles, forms, and modals are not recorded. The file is cleared automatically when you push a new screen, so each screen starts with a clean event log. The terminal message remains the primary feedback.
```jsonl
{"type":"click","choice":"a","text":"Option A - Simple Layout","timestamp":1706000101}
@@ -264,7 +311,7 @@ If `$STATE_DIR/events` doesn't exist, the user didn't interact with the browser
- **Explain the question on each page** — "Which layout feels more professional?" not just "Pick one"
- **Iterate before advancing** — if feedback changes current screen, write a new version
- **2-4 options max** per screen
- **Use real content when it matters** — for a photography portfolio, use actual images (Unsplash). Placeholder content obscures design issues.
- **Use local assets when images matter** — if the project has relevant images, reference them through `/files/<basename>`. Do not pull images from remote URLs just to make a mockup feel polished.
- **Keep mockups simple** — focus on layout and structure, not pixel-perfect design
## File Naming

View File

@@ -14,22 +14,26 @@ Subagent (general-purpose):
## What Was Implemented
{DESCRIPTION}
[DESCRIPTION]
## Requirements / Plan
{PLAN_OR_REQUIREMENTS}
[PLAN_OR_REQUIREMENTS]
## Git Range to Review
**Base:** {BASE_SHA}
**Head:** {HEAD_SHA}
**Base:** [BASE_SHA]
**Head:** [HEAD_SHA]
```bash
git diff --stat {BASE_SHA}..{HEAD_SHA}
git diff {BASE_SHA}..{HEAD_SHA}
git diff --stat [BASE_SHA]..[HEAD_SHA]
git diff [BASE_SHA]..[HEAD_SHA]
```
## Read-Only Review
Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout.
## What to Check
**Plan alignment:**
@@ -122,10 +126,10 @@ Subagent (general-purpose):
```
**Placeholders:**
- `{DESCRIPTION}` — brief summary of what was built
- `{PLAN_OR_REQUIREMENTS}` — what it should do (plan file path, task text, or requirements)
- `{BASE_SHA}` — starting commit
- `{HEAD_SHA}` — ending commit
- `[DESCRIPTION]` — brief summary of what was built
- `[PLAN_OR_REQUIREMENTS]` — what it should do (plan file path, task text, or requirements)
- `[BASE_SHA]` — starting commit
- `[HEAD_SHA]` — ending commit
**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment

View File

@@ -18,6 +18,22 @@ Subagent (general-purpose):
[From implementer's report]
## Git Range to Review
**Base:** [BASE_SHA — commit before this task]
**Head:** [HEAD_SHA — current commit]
```bash
git diff --stat [BASE_SHA]..[HEAD_SHA]
git diff [BASE_SHA]..[HEAD_SHA]
```
Only read files in this diff. Do not crawl the broader codebase.
## Read-Only Review
Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout.
## CRITICAL: Do Not Trust the Report
The implementer finished suspiciously quickly. Their report may be incomplete,

View File

@@ -237,7 +237,7 @@ If you catch yourself thinking:
- "Is that not happening?" - You assumed without verifying
- "Will it show us...?" - You should have added evidence gathering
- "Stop guessing" - You're proposing fixes without understanding
- "Ultrathink this" - Question fundamentals, not just symptoms
- "Ultra-think this" - Question fundamentals, not just symptoms
- "We're stuck?" (frustrated) - Your approach isn't working
**When you see these:** STOP. Return to Phase 1.

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
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
@@ -102,15 +102,15 @@ These thoughts mean STOP—you're rationalizing:
When multiple skills could apply, use this order:
1. **Process skills first** (brainstorming, debugging) - these determine HOW to approach the task
1. **Process skills first** (brainstorming, systematic-debugging) - these determine HOW to approach the task
2. **Implementation skills second** (frontend-design, mcp-builder) - these guide execution
"Let's build X" → brainstorming first, then implementation skills.
"Fix this bug" → debugging first, then domain-specific skills.
"Fix this bug" → systematic-debugging first, then domain-specific skills.
## Skill Types
**Rigid** (TDD, debugging): Follow exactly. Don't adapt away discipline.
**Rigid** (TDD, systematic-debugging): Follow exactly. Don't adapt away discipline.
**Flexible** (patterns): Adapt principles to context.

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

@@ -9,13 +9,18 @@
*/
const { spawn } = require('child_process');
const crypto = require('crypto');
const http = require('http');
const net = require('net');
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.cjs');
const ALPINE_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/alpine.js');
const ALPINE_PROVENANCE_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/alpine.provenance.json');
const ALPINE_NOTICES_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md');
const TEST_PORT = 3334;
const TEST_DIR = '/tmp/brainstorm-test';
const CONTENT_DIR = path.join(TEST_DIR, 'content');
@@ -45,6 +50,29 @@ async function fetch(url) {
});
}
function sha256File(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function rawHttpRequest(requestTarget) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: 'localhost', port: TEST_PORT }, () => {
socket.write(`GET ${requestTarget} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`);
});
let data = '';
socket.on('data', chunk => data += chunk.toString());
socket.on('end', () => {
const statusLine = data.split('\r\n')[0];
const match = statusLine.match(/^HTTP\/1\.1 (\d{3})/);
resolve({
status: match ? Number(match[1]) : 0
});
});
socket.on('error', reject);
});
}
function startServer() {
return spawn('node', [SERVER_PATH], {
env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR }
@@ -92,6 +120,32 @@ async function runTests() {
}
try {
// ========== Vendored Alpine ==========
console.log('\n--- Vendored Alpine ---');
await test('vendored Alpine provenance is complete and matches artifact hash', () => {
assert(fs.existsSync(ALPINE_PATH), 'alpine.js should exist');
assert(fs.existsSync(ALPINE_PROVENANCE_PATH), 'alpine.provenance.json should exist');
assert(fs.existsSync(ALPINE_NOTICES_PATH), 'THIRD_PARTY_NOTICES.md should exist');
const provenance = JSON.parse(fs.readFileSync(ALPINE_PROVENANCE_PATH, 'utf-8'));
assert.strictEqual(provenance.name, 'alpinejs');
assert.strictEqual(provenance.version, '3.15.12');
assert.strictEqual(provenance.license, 'MIT');
assert.strictEqual(provenance.sourceUrl, 'https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz');
assert.strictEqual(provenance.sourcePackagePath, 'package/dist/cdn.min.js');
assert.strictEqual(provenance.localPath, 'skills/brainstorming/scripts/vendor/alpine.js');
assert.strictEqual(provenance.sha256, '57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f');
assert.strictEqual(provenance.approvalArtifact, 'SUP-215');
assert.strictEqual(sha256File(ALPINE_PATH), provenance.sha256);
const notices = fs.readFileSync(ALPINE_NOTICES_PATH, 'utf-8');
assert(notices.includes('Alpine.js'), 'Notice should name Alpine.js');
assert(notices.includes('MIT License'), 'Notice should include MIT license text');
assert(notices.includes('curl -fsSL https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz'), 'Notice should include refresh command');
return Promise.resolve();
});
// ========== Server Startup ==========
console.log('\n--- Server Startup ---');
@@ -136,6 +190,17 @@ async function runTests() {
assert(res.headers['content-type'].includes('text/html'), 'Should be text/html');
});
await test('waiting page does not inject Alpine', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/`);
assert(!res.body.includes('/vendor/alpine.js'), 'Waiting page should not inject Alpine');
});
await test('serves root path when query string is present', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/?from=browser`);
assert.strictEqual(res.status, 200);
assert(res.body.includes('Brainstorm Companion'), 'Should serve the root page by pathname');
});
await test('serves full HTML documents as-is (not wrapped)', async () => {
const fullDoc = '<!DOCTYPE html>\n<html><head><title>Custom</title></head><body><h1>Custom Page</h1></body></html>';
fs.writeFileSync(path.join(CONTENT_DIR, 'full-doc.html'), fullDoc);
@@ -144,6 +209,7 @@ async function runTests() {
const res = await fetch(`http://localhost:${TEST_PORT}/`);
assert(res.body.includes('<h1>Custom Page</h1>'), 'Should contain original content');
assert(res.body.includes('WebSocket'), 'Should still inject helper.js');
assert(!res.body.includes('/vendor/alpine.js'), 'Should NOT inject Alpine into full documents');
assert(!res.body.includes('indicator-bar'), 'Should NOT wrap in frame template');
});
@@ -157,6 +223,20 @@ async function runTests() {
assert(!res.body.includes('<!-- CONTENT -->'), 'Placeholder should be replaced');
assert(res.body.includes('Pick a layout'), 'Fragment content should be present');
assert(res.body.includes('data-choice="a"'), 'Fragment interactive elements intact');
assert(res.body.includes('<script defer src="/vendor/alpine.js"></script>'), 'Fragment should load Alpine');
assert(res.body.includes('Interact with the mockup, then return to the terminal'), 'Frame copy should be neutral');
});
await test('preserves Alpine attributes in frame-wrapped fragments', async () => {
const fragment = '<div x-data="{ open: false }"><button @click="open = !open">Toggle</button><div x-show="open">Details</div></div>';
fs.writeFileSync(path.join(CONTENT_DIR, 'alpine-fragment.html'), fragment);
await sleep(300);
const res = await fetch(`http://localhost:${TEST_PORT}/`);
assert(res.body.includes('x-data="{ open: false }"'), 'Should preserve x-data');
assert(res.body.includes('@click="open = !open"'), 'Should preserve @click');
assert(res.body.includes('x-show="open"'), 'Should preserve x-show');
assert(res.body.includes('/vendor/alpine.js'), 'Should include Alpine script');
});
await test('serves newest file by mtime', async () => {
@@ -184,6 +264,48 @@ async function runTests() {
assert.strictEqual(res.status, 404);
});
await test('serves files by pathname when query string is present', async () => {
fs.writeFileSync(path.join(CONTENT_DIR, 'asset.png'), 'image-bytes');
const res = await fetch(`http://localhost:${TEST_PORT}/files/asset.png?v=1`);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body, 'image-bytes');
});
await test('serves vendored Alpine from exact vendor route', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/vendor/alpine.js`);
const provenance = JSON.parse(fs.readFileSync(ALPINE_PROVENANCE_PATH, 'utf-8'));
assert.strictEqual(res.status, 200);
assert(res.headers['content-type'].includes('application/javascript'), 'Should be JavaScript');
assert.strictEqual(
crypto.createHash('sha256').update(res.body).digest('hex'),
provenance.sha256,
'Should serve the pinned Alpine artifact'
);
});
await test('serves vendored Alpine when query string is present', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/vendor/alpine.js?v=3.15.12`);
assert.strictEqual(res.status, 200);
assert(res.body.includes('Alpine'), 'Should ignore query string for exact vendor pathname');
});
await test('exact-match vendor route rejects non-allowlisted pathnames', async () => {
const paths = [
'/vendor/unknown.js',
'/vendor/alpine.js/extra',
'/vendor/%2e%2e/alpine.js',
'/vendor/%2E%2E/alpine.js'
];
for (const requestPath of paths) {
const res = await fetch(`http://localhost:${TEST_PORT}${requestPath}`);
assert.strictEqual(res.status, 404, `${requestPath} should 404`);
}
const dotSegmentRes = await rawHttpRequest('/vendor/../alpine.js');
assert.strictEqual(dotSegmentRes.status, 404, 'raw dot-segment vendor path should 404');
});
// ========== WebSocket Communication ==========
console.log('\n--- WebSocket Communication ---');
@@ -396,6 +518,15 @@ async function runTests() {
return Promise.resolve();
});
await test('helper.js keeps indicator fallback copy neutral', () => {
const helperContent = fs.readFileSync(
path.join(__dirname, '../../skills/brainstorming/scripts/helper.js'), 'utf-8'
);
assert(helperContent.includes('Interact with the mockup, then return to the terminal'), 'Should use neutral fallback copy');
assert(!helperContent.includes('Click an option above, then return to the terminal'), 'Should not reset to selection-first copy');
return Promise.resolve();
});
// ========== Frame Template ==========
console.log('\n--- Frame Template Verification ---');

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env bash
# Windows lifecycle tests for the brainstorm server.
#
# Verifies that the brainstorm server survives the 60-second lifecycle
# check on Windows, where OWNER_PID monitoring is disabled because the
# MSYS2 PID namespace is invisible to Node.js.
# Verifies brainstorm server lifecycle behavior, including:
# - Windows/MSYS2 foreground mode and empty OWNER_PID handling
# - Server survival past the 60-second lifecycle check window
# - Dead-at-startup OWNER_PID validation (logged, monitoring disabled)
# - Clean stop-server.sh shutdown
#
# Requirements:
# - Node.js in PATH
@@ -20,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="${SUPERPOWERS_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
START_SCRIPT="$REPO_ROOT/skills/brainstorming/scripts/start-server.sh"
STOP_SCRIPT="$REPO_ROOT/skills/brainstorming/scripts/stop-server.sh"
SERVER_JS="$REPO_ROOT/skills/brainstorming/scripts/server.js"
SERVER_SCRIPT="$REPO_ROOT/skills/brainstorming/scripts/server.cjs"
TEST_DIR="${TMPDIR:-/tmp}/brainstorm-win-test-$$"
@@ -64,7 +66,7 @@ skip() {
wait_for_server_info() {
local dir="$1"
for _ in $(seq 1 50); do
if [[ -f "$dir/.server-info" ]]; then
if [[ -f "$dir/state/server-info" ]]; then
return 0
fi
sleep 0.1
@@ -73,9 +75,9 @@ wait_for_server_info() {
}
get_port_from_info() {
# Read the port from .server-info. Use grep/sed instead of Node.js
# Read the port from state/server-info. Use grep/sed instead of Node.js
# to avoid MSYS2-to-Windows path translation issues.
grep -o '"port":[0-9]*' "$1/.server-info" | head -1 | sed 's/"port"://'
grep -o '"port":[0-9]*' "$1/state/server-info" | head -1 | sed 's/"port"://'
}
http_check() {
@@ -214,11 +216,11 @@ BRAINSTORM_HOST="127.0.0.1" \
BRAINSTORM_URL_HOST="localhost" \
BRAINSTORM_OWNER_PID="" \
BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \
node "$SERVER_JS" > "$TEST_DIR/survival/.server.log" 2>&1 &
node "$SERVER_SCRIPT" > "$TEST_DIR/survival/.server.log" 2>&1 &
SERVER_PID=$!
if ! wait_for_server_info "$TEST_DIR/survival"; then
fail "Server starts successfully" "Server did not write .server-info within 5 seconds"
fail "Server starts successfully" "Server did not write state/server-info within 5 seconds"
kill "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
else
@@ -254,10 +256,15 @@ else
SERVER_PID=""
fi
# ========== Test 5: Bad OWNER_PID causes shutdown (control) ==========
# ========== Test 5: Dead-at-startup OWNER_PID is logged but does not kill the server ==========
#
# The server validates BRAINSTORM_OWNER_PID at startup. If it's already dead,
# the PID resolution was wrong (common on WSL, Tailscale SSH, cross-user
# scenarios). The server logs 'owner-pid-invalid', disables owner monitoring,
# and continues running. The idle timeout becomes the only shutdown trigger.
echo ""
echo "--- Control: Bad OWNER_PID causes shutdown ---"
echo "--- Dead-at-startup OWNER_PID: server survives, logs owner-pid-invalid ---"
mkdir -p "$TEST_DIR/control"
@@ -272,33 +279,41 @@ BRAINSTORM_HOST="127.0.0.1" \
BRAINSTORM_URL_HOST="localhost" \
BRAINSTORM_OWNER_PID="$BAD_PID" \
BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \
node "$SERVER_JS" > "$TEST_DIR/control/.server.log" 2>&1 &
node "$SERVER_SCRIPT" > "$TEST_DIR/control/.server.log" 2>&1 &
CONTROL_PID=$!
if ! wait_for_server_info "$TEST_DIR/control"; then
fail "Control server starts" "Server did not write .server-info within 5 seconds"
fail "Control server starts" "Server did not write state/server-info within 5 seconds"
kill "$CONTROL_PID" 2>/dev/null || true
CONTROL_PID=""
else
pass "Control server starts with bad OWNER_PID=$BAD_PID"
pass "Control server starts with dead-at-startup OWNER_PID=$BAD_PID"
echo " Waiting ~75s for lifecycle check to kill server..."
echo " Waiting ~75s to verify server survives past lifecycle check..."
sleep 75
if kill -0 "$CONTROL_PID" 2>/dev/null; then
fail "Control server self-terminates with bad OWNER_PID" \
"Server is still alive (expected it to die)"
kill "$CONTROL_PID" 2>/dev/null || true
pass "Server survives with dead-at-startup OWNER_PID (owner monitoring disabled)"
else
pass "Control server self-terminates with bad OWNER_PID"
fail "Server survives with dead-at-startup OWNER_PID" \
"Server died unexpectedly. Log tail: $(tail -5 "$TEST_DIR/control/.server.log" 2>/dev/null)"
fi
if grep -q "owner-pid-invalid" "$TEST_DIR/control/.server.log" 2>/dev/null; then
pass "Server logs 'owner-pid-invalid' for dead-at-startup PID"
else
fail "Server logs 'owner-pid-invalid' for dead-at-startup PID" \
"Log tail: $(tail -5 "$TEST_DIR/control/.server.log" 2>/dev/null)"
fi
if grep -q "owner process exited" "$TEST_DIR/control/.server.log" 2>/dev/null; then
pass "Control server logs 'owner process exited'"
fail "No spurious 'owner process exited' log" \
"Found 'owner process exited' but owner monitoring should be disabled"
else
fail "Control server logs 'owner process exited'" \
"Log tail: $(tail -5 "$TEST_DIR/control/.server.log" 2>/dev/null)"
pass "No spurious 'owner process exited' log"
fi
kill "$CONTROL_PID" 2>/dev/null || true
fi
wait "$CONTROL_PID" 2>/dev/null || true
@@ -309,16 +324,16 @@ CONTROL_PID=""
echo ""
echo "--- Clean Shutdown ---"
mkdir -p "$TEST_DIR/stop-test"
mkdir -p "$TEST_DIR/stop-test/state"
BRAINSTORM_DIR="$TEST_DIR/stop-test" \
BRAINSTORM_HOST="127.0.0.1" \
BRAINSTORM_URL_HOST="localhost" \
BRAINSTORM_OWNER_PID="" \
BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \
node "$SERVER_JS" > "$TEST_DIR/stop-test/.server.log" 2>&1 &
node "$SERVER_SCRIPT" > "$TEST_DIR/stop-test/.server.log" 2>&1 &
STOP_TEST_PID=$!
echo "$STOP_TEST_PID" > "$TEST_DIR/stop-test/.server.pid"
echo "$STOP_TEST_PID" > "$TEST_DIR/stop-test/state/server.pid"
if ! wait_for_server_info "$TEST_DIR/stop-test"; then
fail "Stop-test server starts" "Server did not start"

View File

@@ -178,7 +178,9 @@ write_upstream_fixture() {
"$repo/.private-journal" \
"$repo/assets" \
"$repo/evals/drill" \
"$repo/hooks" \
"$repo/scripts" \
"$repo/skills/brainstorming/scripts/vendor" \
"$repo/skills/example"
if [[ "$with_pure_ignored" == "1" ]]; then
@@ -218,10 +220,68 @@ EOF
printf 'png fixture\n' > "$repo/assets/app-icon.png"
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'
# Example Skill
Fixture content.
EOF
cat > "$repo/skills/brainstorming/scripts/server.cjs" <<'EOF'
console.log('fixture server')
EOF
cat > "$repo/skills/brainstorming/scripts/helper.js" <<'EOF'
window.fixtureHelper = true
EOF
cat > "$repo/skills/brainstorming/scripts/frame-template.html" <<'EOF'
<html><body><!-- CONTENT --></body></html>
EOF
printf 'fixture alpine\n' > "$repo/skills/brainstorming/scripts/vendor/alpine.js"
cat > "$repo/skills/brainstorming/scripts/vendor/alpine.provenance.json" <<'EOF'
{"name":"alpinejs","version":"3.15.12","localPath":"skills/brainstorming/scripts/vendor/alpine.js","sha256":"fixture","approvalArtifact":"SUP-215"}
EOF
cat > "$repo/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md" <<'EOF'
# Third-Party Notices
Alpine.js fixture notice.
EOF
printf 'tracked keep\n' > "$repo/.private-journal/keep.txt"
@@ -236,8 +296,18 @@ EOF
assets/app-icon.png \
assets/superpowers-small.svg \
evals/drill/README.md \
hooks/hooks-codex.json \
hooks/run-hook.cmd \
hooks/session-start \
hooks/session-start-codex \
package.json \
scripts/sync-to-codex-plugin.sh \
skills/brainstorming/scripts/server.cjs \
skills/brainstorming/scripts/helper.js \
skills/brainstorming/scripts/frame-template.html \
skills/brainstorming/scripts/vendor/alpine.js \
skills/brainstorming/scripts/vendor/alpine.provenance.json \
skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md \
skills/example/SKILL.md
git -C "$repo" add -f .private-journal/keep.txt
@@ -293,6 +363,8 @@ write_synced_destination_fixture() {
"$repo/plugins/superpowers/.codex-plugin" \
"$repo/plugins/superpowers/.private-journal" \
"$repo/plugins/superpowers/assets" \
"$repo/plugins/superpowers/hooks" \
"$repo/plugins/superpowers/skills/brainstorming/scripts/vendor" \
"$repo/plugins/superpowers/skills/example/agents" \
"$repo/plugins/superpowers/skills/example"
@@ -309,10 +381,68 @@ EOF
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'
# Example Skill
Fixture content.
EOF
cat > "$repo/plugins/superpowers/skills/brainstorming/scripts/server.cjs" <<'EOF'
console.log('fixture server')
EOF
cat > "$repo/plugins/superpowers/skills/brainstorming/scripts/helper.js" <<'EOF'
window.fixtureHelper = true
EOF
cat > "$repo/plugins/superpowers/skills/brainstorming/scripts/frame-template.html" <<'EOF'
<html><body><!-- CONTENT --></body></html>
EOF
printf 'fixture alpine\n' > "$repo/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.js"
cat > "$repo/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.provenance.json" <<'EOF'
{"name":"alpinejs","version":"3.15.12","localPath":"skills/brainstorming/scripts/vendor/alpine.js","sha256":"fixture","approvalArtifact":"SUP-215"}
EOF
cat > "$repo/plugins/superpowers/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md" <<'EOF'
# Third-Party Notices
Alpine.js fixture notice.
EOF
cat > "$repo/plugins/superpowers/skills/example/agents/openai.yaml" <<'EOF'
@@ -327,6 +457,16 @@ EOF
plugins/superpowers/.codex-plugin/plugin.json \
plugins/superpowers/assets/app-icon.png \
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/brainstorming/scripts/server.cjs \
plugins/superpowers/skills/brainstorming/scripts/helper.js \
plugins/superpowers/skills/brainstorming/scripts/frame-template.html \
plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.js \
plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.provenance.json \
plugins/superpowers/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md \
plugins/superpowers/skills/example/agents/openai.yaml \
plugins/superpowers/skills/example/SKILL.md \
plugins/superpowers/.private-journal/keep.txt
@@ -345,6 +485,46 @@ write_stale_ignored_destination_fixture() {
commit_fixture "$repo" "Initial stale ignored destination fixture"
}
write_outdated_destination_fixture() {
local repo="$1"
mkdir -p \
"$repo/plugins/superpowers/.codex-plugin" \
"$repo/plugins/superpowers/assets" \
"$repo/plugins/superpowers/skills/example"
cat > "$repo/plugins/superpowers/.codex-plugin/plugin.json" <<'EOF'
{
"name": "superpowers",
"version": "0.0.1"
}
EOF
printf 'old png fixture\n' > "$repo/plugins/superpowers/assets/app-icon.png"
cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
# Example Skill
Old destination content.
EOF
git -C "$repo" add \
plugins/superpowers/.codex-plugin/plugin.json \
plugins/superpowers/assets/app-icon.png \
plugins/superpowers/skills/example/SKILL.md
commit_fixture "$repo" "Initial outdated destination fixture"
}
attach_origin_remote() {
local repo="$1"
local remote="$2"
git init -q --bare "$remote"
git -C "$repo" remote add origin "$remote"
git -C "$repo" push -u origin main --quiet
}
write_fake_gh() {
local bin_dir="$1"
@@ -358,6 +538,29 @@ if [[ "${1:-}" == "auth" && "${2:-}" == "status" ]]; then
exit 0
fi
if [[ "${1:-}" == "pr" && "${2:-}" == "create" ]]; then
shift 2
body=""
while [[ $# -gt 0 ]]; do
case "$1" in
--body)
body="${2:-}"
shift 2
;;
*)
shift
;;
esac
done
if [[ -n "${FAKE_GH_PR_BODY_FILE:-}" ]]; then
printf '%s' "$body" > "$FAKE_GH_PR_BODY_FILE"
fi
echo "https://github.com/prime-radiant-inc/openai-codex-plugins/pull/123"
exit 0
fi
echo "unexpected gh invocation: $*" >&2
exit 1
EOF
@@ -406,6 +609,24 @@ run_apply() {
PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -y --local "$dest" 2>&1
}
run_apply_with_pr_capture() {
local upstream="$1"
local dest="$2"
local fake_bin="$3"
local body_file="$4"
FAKE_GH_PR_BODY_FILE="$body_file" PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -y --local "$dest" 2>&1
}
run_bootstrap_apply_with_pr_capture() {
local upstream="$1"
local dest="$2"
local fake_bin="$3"
local body_file="$4"
FAKE_GH_PR_BODY_FILE="$body_file" PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -y --bootstrap --local "$dest" 2>&1
}
run_help() {
local upstream="$1"
local fake_bin="$2"
@@ -431,11 +652,15 @@ main() {
local stale_dest
local dirty_apply_dest
local dirty_apply_dest_branch
local changed_apply_dest
local changed_apply_remote
local noop_apply_dest
local noop_apply_dest_branch
local fake_bin
local bootstrap_dest
local bootstrap_dest_branch
local bootstrap_apply_dest
local bootstrap_apply_remote
local preview_status
local preview_output
local preview_section
@@ -450,12 +675,26 @@ main() {
local stale_preview_section
local dirty_apply_status
local dirty_apply_output
local changed_apply_status
local changed_apply_output
local changed_apply_pr_body_path
local changed_apply_pr_body
local bootstrap_apply_status
local bootstrap_apply_output
local bootstrap_apply_pr_body_path
local bootstrap_apply_pr_body
local noop_apply_status
local noop_apply_output
local help_output
local script_source
local dirty_skill_path
local changed_apply_alpine_path
local changed_apply_alpine_provenance_path
local changed_apply_alpine_notice_path
local noop_openai_metadata_path
local noop_alpine_path
local noop_alpine_provenance_path
local noop_alpine_notice_path
echo "=== Test: sync-to-codex-plugin dry-run regression ==="
@@ -469,9 +708,13 @@ main() {
stale_dest="$TEST_ROOT/stale-destination"
dirty_apply_dest="$TEST_ROOT/dirty-apply-destination"
dirty_apply_dest_branch="fixture/dirty-apply-target"
changed_apply_dest="$TEST_ROOT/changed-apply-destination"
changed_apply_remote="$TEST_ROOT/changed-apply-remote.git"
noop_apply_dest="$TEST_ROOT/noop-apply-destination"
noop_apply_dest_branch="fixture/noop-apply-target"
bootstrap_dest="$TEST_ROOT/bootstrap-destination"
bootstrap_apply_dest="$TEST_ROOT/bootstrap-apply-destination"
bootstrap_apply_remote="$TEST_ROOT/bootstrap-apply-remote.git"
dest_branch="fixture/preview-target"
bootstrap_dest_branch="fixture/bootstrap-preview-target"
fake_bin="$TEST_ROOT/bin"
@@ -499,6 +742,10 @@ main() {
checkout_fixture_branch "$dirty_apply_dest" "$dirty_apply_dest_branch"
dirty_tracked_destination_skill "$dirty_apply_dest"
init_repo "$changed_apply_dest"
write_outdated_destination_fixture "$changed_apply_dest"
attach_origin_remote "$changed_apply_dest" "$changed_apply_remote"
init_repo "$noop_apply_dest"
write_synced_destination_fixture "$noop_apply_dest"
checkout_fixture_branch "$noop_apply_dest" "$noop_apply_dest_branch"
@@ -507,6 +754,10 @@ main() {
write_bootstrap_destination_fixture "$bootstrap_dest"
checkout_fixture_branch "$bootstrap_dest" "$bootstrap_dest_branch"
init_repo "$bootstrap_apply_dest"
write_bootstrap_destination_fixture "$bootstrap_apply_dest"
attach_origin_remote "$bootstrap_apply_dest" "$bootstrap_apply_remote"
write_fake_gh "$fake_bin"
# This regression test is about dry-run content, so capture the preview
@@ -522,6 +773,12 @@ main() {
stale_preview_status=$?
dirty_apply_output="$(run_apply "$upstream" "$dirty_apply_dest" "$fake_bin")"
dirty_apply_status=$?
changed_apply_pr_body_path="$TEST_ROOT/changed-apply-pr-body.md"
changed_apply_output="$(run_apply_with_pr_capture "$upstream" "$changed_apply_dest" "$fake_bin" "$changed_apply_pr_body_path")"
changed_apply_status=$?
bootstrap_apply_pr_body_path="$TEST_ROOT/bootstrap-apply-pr-body.md"
bootstrap_apply_output="$(run_bootstrap_apply_with_pr_capture "$upstream" "$bootstrap_apply_dest" "$fake_bin" "$bootstrap_apply_pr_body_path")"
bootstrap_apply_status=$?
noop_apply_output="$(run_apply "$upstream" "$noop_apply_dest" "$fake_bin")"
noop_apply_status=$?
missing_manifest_output="$(run_preview_without_manifest "$upstream" "$dest" "$fake_bin")"
@@ -532,7 +789,15 @@ main() {
preview_section="$(printf '%s\n' "$preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')"
stale_preview_section="$(printf '%s\n' "$stale_preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')"
dirty_skill_path="$dirty_apply_dest/plugins/superpowers/skills/example/SKILL.md"
changed_apply_alpine_path="$changed_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.js"
changed_apply_alpine_provenance_path="$changed_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.provenance.json"
changed_apply_alpine_notice_path="$changed_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md"
changed_apply_pr_body="$(cat "$changed_apply_pr_body_path" 2>/dev/null || true)"
bootstrap_apply_pr_body="$(cat "$bootstrap_apply_pr_body_path" 2>/dev/null || true)"
noop_openai_metadata_path="$noop_apply_dest/plugins/superpowers/skills/example/agents/openai.yaml"
noop_alpine_path="$noop_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.js"
noop_alpine_provenance_path="$noop_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/alpine.provenance.json"
noop_alpine_notice_path="$noop_apply_dest/plugins/superpowers/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md"
echo ""
echo "Preview assertions..."
@@ -542,6 +807,10 @@ main() {
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/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_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"
@@ -549,6 +818,12 @@ main() {
assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note"
assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note"
assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file"
assert_contains "$preview_section" "skills/brainstorming/scripts/server.cjs" "Preview includes skill-local server runtime"
assert_contains "$preview_section" "skills/brainstorming/scripts/helper.js" "Preview includes skill-local helper runtime"
assert_contains "$preview_section" "skills/brainstorming/scripts/frame-template.html" "Preview includes skill-local frame template"
assert_contains "$preview_section" "skills/brainstorming/scripts/vendor/alpine.js" "Preview includes vendored Alpine"
assert_contains "$preview_section" "skills/brainstorming/scripts/vendor/alpine.provenance.json" "Preview includes Alpine provenance"
assert_contains "$preview_section" "skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md" "Preview includes Alpine notice"
assert_not_matches "$preview_section" "\\*deleting +skills/example/agents/openai\\.yaml" "Preview preserves destination-owned OpenAI agent metadata"
assert_current_branch "$dest" "$dest_branch" "Preview leaves destination checkout on its original branch"
assert_branch_absent "$dest" "sync/superpowers-*" "Preview does not create sync branch in destination checkout"
@@ -583,6 +858,23 @@ main() {
assert_file_equals "$dirty_skill_path" "# Example Skill
Locally modified fixture content." "Dirty local apply preserves tracked working-tree file content"
assert_equals "$changed_apply_status" "0" "Changed local apply exits successfully"
assert_contains "$changed_apply_output" "PR opened: https://github.com/prime-radiant-inc/openai-codex-plugins/pull/123" "Changed local apply opens PR through fake gh"
assert_contains "$changed_apply_pr_body" $'tool is behaving.\n\nVendored third-party code included in this sync' "Changed local apply PR body separates vendored section"
assert_contains "$changed_apply_pr_body" "Vendored third-party code included in this sync" "Changed local apply PR body includes vendored section"
assert_contains "$changed_apply_pr_body" "skills/brainstorming/scripts/vendor/alpine.js" "Changed local apply PR body includes vendored Alpine path"
assert_contains "$changed_apply_pr_body" "alpinejs 3.15.12" "Changed local apply PR body includes Alpine package/version"
assert_contains "$changed_apply_pr_body" "Approval artifact: SUP-215" "Changed local apply PR body includes approval artifact"
assert_contains "$changed_apply_pr_body" 'License notice: `skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md`' "Changed local apply PR body includes license notice path"
assert_contains "$changed_apply_pr_body" 'Provenance: `skills/brainstorming/scripts/vendor/alpine.provenance.json`' "Changed local apply PR body includes provenance path"
assert_contains "$changed_apply_pr_body" 'SHA256: `fixture`' "Changed local apply PR body includes SHA256"
assert_file_equals "$changed_apply_alpine_path" "fixture alpine" "Changed local apply writes vendored Alpine"
assert_file_equals "$changed_apply_alpine_provenance_path" "{\"name\":\"alpinejs\",\"version\":\"3.15.12\",\"localPath\":\"skills/brainstorming/scripts/vendor/alpine.js\",\"sha256\":\"fixture\",\"approvalArtifact\":\"SUP-215\"}" "Changed local apply writes Alpine provenance"
assert_contains "$(cat "$changed_apply_alpine_notice_path")" "Alpine.js fixture notice." "Changed local apply writes Alpine notice"
assert_equals "$bootstrap_apply_status" "0" "Bootstrap local apply exits successfully"
assert_contains "$bootstrap_apply_output" "PR opened: https://github.com/prime-radiant-inc/openai-codex-plugins/pull/123" "Bootstrap local apply opens PR through fake gh"
assert_contains "$bootstrap_apply_pr_body" "Vendored third-party code included in this sync" "Bootstrap local apply PR body includes vendored section"
assert_contains "$bootstrap_apply_pr_body" "Approval artifact: SUP-215" "Bootstrap local apply PR body includes approval artifact"
assert_equals "$noop_apply_status" "0" "Clean no-op local apply exits successfully"
assert_contains "$noop_apply_output" "No changes — embedded plugin was already in sync with upstream" "Clean no-op local apply reports no changes"
assert_current_branch "$noop_apply_dest" "$noop_apply_dest_branch" "Clean no-op local apply leaves destination checkout on its original branch"
@@ -590,6 +882,9 @@ Locally modified fixture content." "Dirty local apply preserves tracked working-
assert_file_equals "$noop_openai_metadata_path" "interface:
display_name: \"Example\"
short_description: \"Destination-owned OpenAI metadata\"" "Clean no-op local apply preserves OpenAI agent metadata"
assert_file_equals "$noop_alpine_path" "fixture alpine" "Clean no-op local apply preserves vendored Alpine"
assert_file_equals "$noop_alpine_provenance_path" "{\"name\":\"alpinejs\",\"version\":\"3.15.12\",\"localPath\":\"skills/brainstorming/scripts/vendor/alpine.js\",\"sha256\":\"fixture\",\"approvalArtifact\":\"SUP-215\"}" "Clean no-op local apply preserves Alpine provenance"
assert_contains "$(cat "$noop_alpine_notice_path")" "Alpine.js fixture notice." "Clean no-op local apply preserves Alpine notice"
echo ""
echo "Missing manifest assertions..."
@@ -605,6 +900,7 @@ Locally modified fixture content." "Dirty local apply preserves tracked working-
assert_not_contains "$script_source" "regenerated inline" "Source drops regenerated inline phrasing"
assert_not_contains "$script_source" "Brand Assets directory" "Source drops Brand Assets directory phrasing"
assert_not_contains "$script_source" "--assets-src" "Source drops --assets-src"
assert_contains "$script_source" "Vendored third-party code included in this sync" "Source calls out vendored third-party code in sync PR body"
if [[ $FAILURES -ne 0 ]]; then
echo ""

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));
}
});