mirror of
https://github.com/obra/superpowers.git
synced 2026-06-14 06:39:05 +08:00
Compare commits
1 Commits
update-por
...
codex/expl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63101959b5 |
@@ -1,862 +0,0 @@
|
||||
# Porting Superpowers to a New Harness
|
||||
|
||||
This guide explains how to add support for a new harness — an IDE, CLI, or
|
||||
agent runner that isn't Claude Code — so that Superpowers skills auto-trigger
|
||||
there the same way they do natively.
|
||||
|
||||
It is written in two layers. **Part 1–3** explain how the system works and how
|
||||
to tell whether a harness can be supported at all; read these before you touch
|
||||
anything. **Part 4–8** are a prescriptive procedure for an agent (supervised by
|
||||
a human partner) to execute the port end to end, through distribution. An
|
||||
appendix indexes the current reference integrations so you can copy the closest
|
||||
one.
|
||||
|
||||
The integration mechanism differs across harnesses, and it will keep changing.
|
||||
This guide deliberately teaches the **invariants** — the things that must be
|
||||
true no matter the mechanism — and points you at a live reference implementation
|
||||
to copy. When this guide and the code disagree, the code wins; fix the guide.
|
||||
|
||||
## Before you start
|
||||
|
||||
Adding a harness is the highest-stakes contribution type in this repo. Before
|
||||
writing anything:
|
||||
|
||||
- Read `CLAUDE.md` and `.github/PULL_REQUEST_TEMPLATE.md` in full — the
|
||||
contributor rules and the new-harness PR requirements are not optional.
|
||||
- Search open **and closed** PRs for a prior attempt at this harness. If one
|
||||
exists, understand why it stalled before starting your own.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — How Superpowers works across harnesses
|
||||
|
||||
Superpowers is the same content everywhere. What changes per harness is the thin
|
||||
layer that delivers that content to the model and translates its instructions
|
||||
into the harness's native tools. Three components:
|
||||
|
||||
1. **Skills (harness-agnostic).** Everything in `skills/` is the source of
|
||||
truth, shared verbatim by every harness. Skills are written to describe
|
||||
*actions* — "invoke a skill", "read a file", "dispatch a subagent", "create a
|
||||
todo" — and never name a specific tool. This is what lets one skill body run
|
||||
on Claude Code, Codex, Gemini, pi, and the rest without edits.
|
||||
|
||||
2. **Tool mapping (per-harness).** Each harness needs the action vocabulary
|
||||
translated into its real tool names. That translation lives in
|
||||
`skills/using-superpowers/references/<harness>-tools.md` and/or inline in the
|
||||
harness's bootstrap injector (see Part 5). It says, e.g., "*dispatch a
|
||||
subagent* → call `task` with `subagent_type`."
|
||||
|
||||
3. **Bootstrap (per-harness).** At the start of every session, the full
|
||||
`skills/using-superpowers/SKILL.md` is injected into the model's context,
|
||||
wrapped in `<EXTREMELY_IMPORTANT>` tags, with the tool mapping appended. That
|
||||
injected skill is what teaches the model that skills exist and that it must
|
||||
check for a relevant skill before acting. **The bootstrap is the entire
|
||||
integration.** Without it, the skill files are inert — present on disk, never
|
||||
invoked.
|
||||
|
||||
### Two rules that make this work
|
||||
|
||||
**1. Skills name actions, not tools.** Do **not** edit skill bodies to fit your
|
||||
harness. Porting adds a tool-mapping reference and a bootstrap injector; it
|
||||
never reaches into `skills/*/SKILL.md` to swap tool names. (The project's
|
||||
contributor guidelines treat skill content as carefully-tuned behavior-shaping
|
||||
code; rewording it for "compliance" is rejected on sight.)
|
||||
|
||||
**2. Everything ships through the harness's own install mechanism. Never edit the
|
||||
user's files.** The bootstrap, the skills, and the tool mapping all get delivered
|
||||
*as part of what the harness installs* — a plugin, an extension, a marketplace
|
||||
entry, an extension-bundled context file. A port **must not** reach into a user's
|
||||
global or personal config (`~/.gemini/config/AGENTS.md`, `settings.json`,
|
||||
`trustedFolders.json`, a hand-edited `~/.bashrc`, etc.) to inject anything. The
|
||||
harness owns what it loads; your install artifact is the only thing you get to
|
||||
write. If the install mechanism genuinely can't carry the bootstrap, that is a
|
||||
limitation to surface (Part 6) — never a license to hand-edit the user's config.
|
||||
(Shape C is *not* an exception: Gemini's context file is fine because it ships
|
||||
*inside the installed extension* and is declared by the manifest's
|
||||
`contextFileName` — the harness loads the extension's own file, not a file you
|
||||
edited in the user's home.)
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Can this harness be supported?
|
||||
|
||||
A harness can support Superpowers only if it can do all of the following. Check
|
||||
these before writing code — if the first one fails, stop.
|
||||
|
||||
### Hard requirement: automatic session-start injection
|
||||
|
||||
The harness must let you inject text into the model's context **at the start of
|
||||
every session, with no per-session opt-in by your human partner.** This is the
|
||||
one non-negotiable capability. It can take any form:
|
||||
|
||||
- a **hook/event system** that runs a shell command at session start and reads
|
||||
its stdout (Claude Code, Codex, Cursor, Copilot CLI), or
|
||||
- an **in-process plugin/extension** with a session-start or message lifecycle
|
||||
callback that can mutate the message array (OpenCode, pi), or
|
||||
- an **instructions-file** convention where the harness loads a context file that
|
||||
*your installed extension ships and declares* (e.g. Gemini's `contextFileName`
|
||||
pointing at the extension's own `GEMINI.md`) — not a file you edit in the user's
|
||||
home.
|
||||
|
||||
If the only way to get Superpowers in front of the model is for your human
|
||||
partner to opt in each session (paste a prompt, run a command, enable a mode),
|
||||
the harness
|
||||
**cannot** be properly supported. The acceptance test in Part 3 will fail, and
|
||||
the PR will be closed. This is the single most common reason a "port" isn't a
|
||||
real port.
|
||||
|
||||
### The rest of the capability checklist
|
||||
|
||||
| Capability | Why it's needed | If absent |
|
||||
|---|---|---|
|
||||
| **Skill discovery + invocation** | The model must be able to load a skill's full content on demand | If there's no native skill tool, the sanctioned fallback is to `read` the relevant `SKILL.md` directly — see Part 5. A harness with neither a skill tool nor file-read cannot work. |
|
||||
| **File read / write / edit** | Nearly every skill manipulates files | Essential. No workaround. |
|
||||
| **Run shell commands** | TDD, verification, git workflows | Essential. |
|
||||
| **Subagent / task dispatch** | `dispatching-parallel-agents`, `subagent-driven-development` | Degradable: if unavailable, those specific skills tell the model to do the work inline or report the missing capability — *never* to invent a `Task` call. Some harnesses gate this behind a config flag (e.g. Codex needs multi-agent enabled). |
|
||||
| **Todo / task tracking** | Progress tracking in several skills | Degradable: fall back to a plan file or `TODO.md`. |
|
||||
| **Web fetch / search** | A few skills | Degradable. |
|
||||
| **Shell or polyglot script execution (Windows)** | Only for the shell-hook shape, only if you want Windows support | See Part 7. In-process-plugin harnesses sidestep this entirely. |
|
||||
|
||||
"Degradable" means: the skill already has fallback wording for the missing
|
||||
tool. Your job in the tool mapping is to point at the real tool when it exists
|
||||
and reuse that fallback wording when it doesn't.
|
||||
|
||||
### You may not need a new directory at all
|
||||
|
||||
Some "new harnesses" are really existing integrations under a different
|
||||
installer. Factory's Droid consumes the Claude Code plugin via its own `plugin
|
||||
install` command and needs no new files here. Antigravity (`agy`) goes further:
|
||||
`agy plugin install <repo-url>` imports the Claude Code plugin's skills **and its
|
||||
`SessionStart` hook, which agy then runs** — so the bootstrap fires the Shape-A
|
||||
way (Part 4) with no new manifest, hook config, or installer. Its entire
|
||||
integration is a tool-mapping reference plus a README section.
|
||||
|
||||
**So before building anything, install the existing Claude Code (or Gemini)
|
||||
plugin into the harness and run the acceptance test (Part 3).** If the harness
|
||||
already loads the skills and runs the hook, you are done — a port that adds
|
||||
nothing to this repo but a tool mapping and a README paragraph is a perfectly
|
||||
good outcome. Don't assume a derived harness needs its own bootstrap mechanism
|
||||
until you've watched the existing one fail.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Definition of done
|
||||
|
||||
A port is finished when **all** of these are true:
|
||||
|
||||
1. The `using-superpowers` bootstrap loads at session start, every session, with
|
||||
no per-session opt-in.
|
||||
2. A tool mapping exists for the harness (in
|
||||
`references/<harness>-tools.md`, inline in the bootstrap, or both — per Part 5).
|
||||
3. Skills can actually be invoked — natively, or via the documented
|
||||
read-`SKILL.md` fallback — and the model follows them.
|
||||
4. **The acceptance test passes.** In a clean session, the user message:
|
||||
|
||||
> Let's make a react todo list
|
||||
|
||||
auto-triggers the `brainstorming` skill *before any code is written*. Capture
|
||||
the full transcript — the PR requires it.
|
||||
5. Tests cover the integration (Part 5) and pass.
|
||||
6. A real user can install it through the harness's own mechanism (not by
|
||||
hand-copying files), and the version is tracked in `.version-bump.json` where
|
||||
applicable (Part 6). Note that some installers rewrite or strip the manifest on
|
||||
install (one drops it to just `{"name": …}`), so "the *installed* files report
|
||||
the repo version" is not always achievable — track the version at the source
|
||||
manifest and don't treat a rewritten installed manifest as a failure.
|
||||
|
||||
A quick smoke check before the full acceptance test: start a session and ask the
|
||||
model to describe its superpowers. If the bootstrap injected, it knows it has
|
||||
them. (OpenCode's install doc uses `opencode run --print-logs "hello" 2>&1 |
|
||||
grep -i superpowers` for the same goal via a different mechanism — log-grep
|
||||
rather than asking the model; the `2>&1` matters because logs go to stderr. Find
|
||||
your harness's equivalent.)
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Choose your integration shape
|
||||
|
||||
There are three structural shapes, distinguished by *how you get the bootstrap
|
||||
in front of the model*. Pick the one that matches what your harness exposes,
|
||||
then copy that reference implementation. The shape determines almost everything
|
||||
in Part 5 — the steps below branch on it.
|
||||
|
||||
### How to tell which shape you have
|
||||
|
||||
Before routing, learn the harness's *actual* mechanism — and don't assume it's
|
||||
well documented or that it behaves like whatever harness it forked from.
|
||||
|
||||
**Find the surface:**
|
||||
|
||||
- **Search the web for the harness's docs** (extension / plugin / hook / skill /
|
||||
MCP / "context file" / "rules file"). Vendor tools change fast; search rather
|
||||
than trust training knowledge.
|
||||
- **Find and read an existing third-party extension/plugin for the harness.** A
|
||||
real working example beats docs — it shows the manifest shape, the install
|
||||
command, and which components the harness actually loads.
|
||||
- Check what the harness loads at startup: a settings file? an extensions
|
||||
directory? a per-project or global instructions file (`AGENTS.md`, `<NAME>.md`)?
|
||||
|
||||
**If it's underdocumented, reverse-engineer it empirically** (a real porter has
|
||||
had to do every one of these):
|
||||
|
||||
- `strings` the binary / grep the install tree for hook event names, config
|
||||
paths, and the instructions file it reads.
|
||||
- **Ask the running model to enumerate its own tool names** — e.g. "list the
|
||||
exact machine names of every tool you can call." This is the authoritative way
|
||||
to get tool names without inventing them (see Step 4).
|
||||
- Prove every assumption with a **unique-marker test**: inject a nonsense token
|
||||
through the mechanism you think works, start a fresh session, and confirm the
|
||||
token actually reached the model.
|
||||
|
||||
**A fork does not inherit its parent's behavior.** A harness derived from another
|
||||
(e.g. a Gemini-derived CLI) may expose the parent's manifest fields and
|
||||
`@`-include syntax and *still not honor them the same way*. Verify with a marker;
|
||||
never assume the parent's recipe transfers.
|
||||
|
||||
**The inverse also bites: a derived harness may inherit *more* than you expect.**
|
||||
Antigravity runs the Claude Code plugin's `SessionStart` hook even though `agy`'s
|
||||
own plugin docs describe a different model — and we nearly built a whole
|
||||
context-file scaffold on the false premise that it had no session-start hook.
|
||||
Test the cheap path first: install the existing Claude Code (or Gemini) plugin
|
||||
and run the acceptance test (Part 3) *before* writing any harness-specific
|
||||
bootstrap.
|
||||
|
||||
Then route to a shape:
|
||||
|
||||
- Shell command at session start whose stdout is read → **Shape A**.
|
||||
- Plugin/extension module with lifecycle callbacks you run code in → **Shape B**.
|
||||
- Only ever an always-on instructions file, no hook and no code plugin →
|
||||
**Shape C**.
|
||||
|
||||
**Shapes compose — they are not mutually exclusive.** The *skill-discovery*
|
||||
mechanism and the *bootstrap* mechanism need not be the same shape — but **both
|
||||
must still ride the install mechanism** (rule 2). Decide the two questions
|
||||
separately: *where do skills get discovered?* and *how does the bootstrap reach
|
||||
the model every session?* A harness might install skills via a plugin yet need
|
||||
the bootstrap delivered another install-shipped way (an extension-declared
|
||||
context file, or — see below — by the harness surfacing the installed
|
||||
`using-superpowers` skill's own description at session start). If more than one
|
||||
install-mechanism surface injects automatically, prefer the most reliable. What
|
||||
you may **not** do is bridge a gap by editing the user's global config.
|
||||
|
||||
### Shape A — Shell-hook
|
||||
|
||||
The harness has a hook system that runs a shell command at session start and
|
||||
reads JSON from its stdout. The configured command runs `run-hook.cmd`, a
|
||||
polyglot wrapper that just locates bash and dispatches the named script; the
|
||||
script (`hooks/session-start`, or a harness-specific variant like
|
||||
`hooks/session-start-codex`) is what reads `using-superpowers/SKILL.md` and
|
||||
prints a JSON object whose **field name and nesting differ per harness**.
|
||||
|
||||
- Reference: `hooks/session-start` (and `hooks/session-start-codex`),
|
||||
`hooks/run-hook.cmd`, and the per-harness hook config `hooks/hooks.json`
|
||||
(Claude Code), `hooks/hooks-codex.json` (Codex), `hooks/hooks-cursor.json`
|
||||
(Cursor).
|
||||
- Manifests: `.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json` point the
|
||||
harness at `./skills/` and the right `hooks-*.json`. (Claude Code's
|
||||
`.claude-plugin/plugin.json` sets neither field — it auto-discovers `skills/`
|
||||
and `hooks/hooks.json` by convention.)
|
||||
|
||||
> **A hook *system* is not a session-start *event*.** A harness can have a
|
||||
> `hooks.json` mechanism — and even contain the literal string `SessionStart` in
|
||||
> its binary — while having no hook event that fires at session start and can
|
||||
> inject context. (One real harness only exposed pre/post-tool and stop events;
|
||||
> the `SessionStart` strings were telemetry.) Confirm the *specific event* you
|
||||
> need exists and can write to the model's context before committing to Shape A.
|
||||
> If it can't, the bootstrap belongs in an instructions file (Shape C) instead.
|
||||
|
||||
### Shape B — In-process plugin / extension
|
||||
|
||||
The harness loads a JS/TS module that exposes lifecycle callbacks. You register
|
||||
the skills directory through the harness's API and inject the bootstrap by
|
||||
mutating the message array in code.
|
||||
|
||||
- Reference: `.opencode/plugins/superpowers.js` (JavaScript) and
|
||||
`.pi/extensions/superpowers.ts` (TypeScript). pi is the closest reference for
|
||||
any harness that has **no native skill tool**.
|
||||
|
||||
### Shape C — Instructions-file
|
||||
|
||||
The harness has neither a shell hook nor a code plugin — its session-start
|
||||
surface is a context file that *your installed extension ships and the manifest
|
||||
declares* (e.g. Gemini's `contextFileName` → the extension's own `GEMINI.md`).
|
||||
You can't run code or mutate messages; the extension's context file points at the
|
||||
bootstrap. There is no injector to assemble a string or strip frontmatter — the
|
||||
harness loads the referenced content as-is. **This works only because the file is
|
||||
part of the installed extension** — never substitute "edit the user's global
|
||||
`GEMINI.md`/`AGENTS.md`" for shipping your own (rule 2).
|
||||
|
||||
- Reference: `gemini-extension.json` (manifest, with `contextFileName`),
|
||||
`GEMINI.md` (two `@`-includes — the bootstrap skill and the tool-mapping
|
||||
reference), `skills/using-superpowers/references/gemini-tools.md`.
|
||||
- Note: `@`-include is a Gemini feature. If your harness loads an instructions
|
||||
file but has no include syntax, you must inline the bootstrap content into the
|
||||
file instead.
|
||||
- **Don't trust that an `@`-include is actually expanded — prove it.** A
|
||||
Gemini-*derived* harness can accept `@./path` syntax yet treat it as a *hint
|
||||
the model may choose to read* (it emits a file-read tool call) rather than a
|
||||
guaranteed inline expansion. That's the difference between the bootstrap being
|
||||
reliably present every session and the model maybe-reading it. Run a
|
||||
unique-marker test: if the marker isn't in context *without* a tool call,
|
||||
**inline the content** rather than `@`-include it.
|
||||
|
||||
### Routing table
|
||||
|
||||
| If the harness… | Use shape | Copy from |
|
||||
|---|---|---|
|
||||
| runs a shell command at session start and reads its stdout | A (shell-hook) | Codex (`hooks/session-start-codex` + `hooks/hooks-codex.json` + `.codex-plugin/`) |
|
||||
| is a JS/TS plugin host with session/message lifecycle callbacks | B (in-process) | OpenCode (`.opencode/`) — or pi (`.pi/`) if it has no native skill tool |
|
||||
| ships an extension-declared context file it always loads | C (instructions-file) | Gemini (`gemini-extension.json` + `GEMINI.md` + `references/gemini-tools.md`) |
|
||||
| installs an existing harness's plugin directly, including its hook | none — no new files | Antigravity (`agy plugin install <repo-url>` imports the Claude Code `skills/` + `hooks/` and runs the `SessionStart` hook), Factory Droid |
|
||||
|
||||
Most real harnesses fit one row cleanly. The last row is the cheapest outcome and
|
||||
the one to rule out first (Part 2): if the harness just runs the existing plugin,
|
||||
you write almost nothing. Rule 2 still holds in every row — the bootstrap rides
|
||||
the install mechanism, never a user-config edit.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — The porting procedure
|
||||
|
||||
### Step 1 — Study the closest reference implementation
|
||||
|
||||
Open the files named in Part 4 for your shape and read them end to end. The
|
||||
patterns below are summaries; the code is the spec.
|
||||
|
||||
### Step 2 — Create the manifest / entry point
|
||||
|
||||
Create whatever the harness uses to recognize the plugin. Match the existing
|
||||
ones in spirit:
|
||||
|
||||
- **Shape A:** a `*-plugin/plugin.json` (see `.codex-plugin/plugin.json`) with
|
||||
`name`, `version`, `description`, author/license/keywords, `"skills":
|
||||
"./skills/"`, and `"hooks": "./hooks/hooks-<harness>.json"`. Plus the
|
||||
`hooks-<harness>.json` itself, registering a session-start hook whose command
|
||||
invokes `run-hook.cmd`.
|
||||
- **Shape B:** the module the harness loads (e.g. `.<harness>/plugins/*.js`) plus
|
||||
whatever package metadata it needs to be discovered. The committed package
|
||||
metadata is the **repo-root `package.json`**: `main` points at the OpenCode
|
||||
plugin, the `pi` field (`pi.extensions`, `pi.skills`) plus the `pi-package`
|
||||
keyword declare the pi extension. Per-harness local manifests and lockfiles are
|
||||
kept out of git — `.opencode/.gitignore` excludes `node_modules`,
|
||||
`package.json`, and lockfiles. Do the same for your harness's *local* install
|
||||
artifacts so they don't pollute the repo — but never gitignore the repo-root
|
||||
`package.json`, which is the tracked source of truth.
|
||||
- **Build/dependency check.** Decide how the harness loads your module:
|
||||
does it run the source directly (pi's `.ts` is referenced as-is from
|
||||
`package.json`; OpenCode ships plain `.js`), or does it need a transpile/build
|
||||
step? Superpowers is zero-runtime-dependency. pi's `import type
|
||||
{ ExtensionAPI }` works specifically because the harness runs the `.ts`
|
||||
directly, supplies that type at load, and the repo never type-checks the file
|
||||
in CI — the import isn't even declared as a dependency. If *your* harness
|
||||
actually type-checks or bundles the plugin, that breaks: an undeclared type
|
||||
import fails, and the PR rules only carve out *runtime* deps for new
|
||||
harnesses, not dev/type packages. If you hit this, confirm the approach with
|
||||
the maintainer rather than quietly adding a dependency. Keep any build output
|
||||
out of git and document the command.
|
||||
- **Shape C (instructions-file):** a small manifest (see `gemini-extension.json`:
|
||||
`name`, `description`, `version`, `contextFileName`) plus the context file
|
||||
itself (`GEMINI.md` is just two `@`-includes: the bootstrap skill and the
|
||||
tool-mapping reference). The Gemini manifest has no `skills` field — Gemini
|
||||
auto-discovers the `skills/` directory bundled in the installed extension. If
|
||||
your harness has a native skill tool but no manifest field to register the
|
||||
directory, you must find its discovery convention (read its extension docs),
|
||||
then verify empirically: after wiring, ask the model to list its available
|
||||
skills — if the bundled skills don't appear, discovery isn't working yet.
|
||||
|
||||
### Step 3 — Wire the bootstrap injection
|
||||
|
||||
This is the heart of the port. The shared goal: at session start, get the
|
||||
`using-superpowers` skill content (wrapped in `<EXTREMELY_IMPORTANT>` tags) plus
|
||||
the harness's tool mapping in front of the model, with a note that the skill is
|
||||
already active so the model doesn't try to load it again. *How* you do that —
|
||||
and what you assemble vs. what the harness loads raw — depends entirely on your
|
||||
shape. Do **not** apply one shape's recipe to another.
|
||||
|
||||
**Shape A — a script reads `SKILL.md` and prints the harness's JSON.** The
|
||||
dispatched script (`hooks/session-start`) `cat`s the whole `SKILL.md` (frontmatter
|
||||
included — that's fine; it's emitted verbatim), wraps it with the "You have
|
||||
superpowers… for all other skills use the Skill tool" preamble, escapes it, and
|
||||
prints the harness's JSON shape. The tool mapping for Shape A does **not** go
|
||||
inline here — it lives in `references/<harness>-tools.md` (Step 4). Get the JSON
|
||||
output shape exactly right. `hooks/session-start`
|
||||
detects the harness from environment variables and prints *one of three* shapes:
|
||||
|
||||
- Cursor (`CURSOR_PLUGIN_ROOT` set): `{ "additional_context": "…" }`
|
||||
- Claude Code (`CLAUDE_PLUGIN_ROOT` set, `COPILOT_CLI` unset):
|
||||
`{ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "…" } }`
|
||||
- Copilot CLI / SDK standard (else): `{ "additionalContext": "…" }`
|
||||
|
||||
This is a trap. Emitting the wrong field, or an extra one, means the bootstrap
|
||||
either never injects or injects twice (Claude Code reads both
|
||||
`additional_context` and `hookSpecificOutput` without de-duplicating, so emitting
|
||||
both double-injects). Find the
|
||||
exact field, nesting, and event-matcher values your harness expects. Then
|
||||
decide: add a fourth branch to `hooks/session-start`, or — if the harness needs
|
||||
a different bootstrap message or env contract — add a dedicated
|
||||
`hooks/session-start-<harness>` script, the way Codex did. If you add a branch
|
||||
and your harness *also* sets an env var an earlier branch keys on (some harnesses
|
||||
set `CLAUDE_PLUGIN_ROOT` too), order your branch before the one that would
|
||||
otherwise shadow it. Match the harness's
|
||||
own event-matcher strings (Claude Code uses `startup|clear|compact`, Codex
|
||||
`startup|resume|clear`, Cursor `sessionStart`); wrong matchers mean the hook
|
||||
silently never fires.
|
||||
|
||||
The **hook-config schema itself varies per harness** — don't assume the
|
||||
Claude/Codex shape is universal. Compare `hooks/hooks.json`,
|
||||
`hooks/hooks-codex.json`, and `hooks/hooks-cursor.json`: Cursor's uses
|
||||
`"version": 1`, a lowercase `sessionStart` key, a relative
|
||||
`./hooks/run-hook.cmd` command, and omits the `matcher`/`type`/`async` fields the
|
||||
others use. Match your `hooks-<harness>.json` to whichever existing file is
|
||||
closest, not to a single canonical template.
|
||||
|
||||
The hook **command string references a harness-provided plugin-root variable**,
|
||||
and its name differs per harness: `hooks.json` uses `${CLAUDE_PLUGIN_ROOT}`,
|
||||
`hooks-codex.json` uses `${PLUGIN_ROOT}`, Cursor uses a relative path. Use
|
||||
whatever your harness exports. (The `session-start` script re-derives the root
|
||||
itself via `dirname`, so the script body doesn't depend on this — but the
|
||||
command in the manifest does.)
|
||||
|
||||
**Discovering the harness's contract.** The three facts above — env var, JSON
|
||||
field/nesting, matcher strings — are the harness's contract, not Superpowers',
|
||||
so you have to source them. Read the harness's hook docs, or find out
|
||||
empirically: register a throwaway session-start hook that dumps its environment
|
||||
and emits a marker, then observe which env var identifies the harness and
|
||||
whether/how the harness ingests your stdout. Pin these down before writing the
|
||||
real branch.
|
||||
|
||||
**Shape B — assemble the string in code, then inject as a user message.** Here
|
||||
you build the bootstrap yourself: read `SKILL.md`, strip its YAML frontmatter,
|
||||
and assemble `<EXTREMELY_IMPORTANT>` + a short preamble that the skill is already
|
||||
loaded and must not be re-invoked + the stripped body + the inline tool mapping +
|
||||
`</EXTREMELY_IMPORTANT>`. One subtlety the references disagree on: OpenCode's
|
||||
preamble says "do NOT use the skill tool…" (assumes a `skill` tool exists), while
|
||||
pi's just says "do not try to load using-superpowers again." If your harness has
|
||||
no skill tool, use pi's wording, not OpenCode's.
|
||||
|
||||
Inject the result as a **user-role message, not a system message** — system
|
||||
messages bloat tokens when repeated every turn (#750) and multiple system
|
||||
messages break some models (#894). Three things you must replicate:
|
||||
|
||||
- **Dedup guard.** The lifecycle callback can fire repeatedly (OpenCode's
|
||||
transform runs on *every* agent step; pi's `context` fires per turn). Before
|
||||
injecting, check whether a bootstrap marker is already present and skip if so.
|
||||
(The references pick different markers — pi a custom string, OpenCode the
|
||||
`EXTREMELY_IMPORTANT` tag; matching the tag is more robust since it needs no
|
||||
harness-specific constant.) Cache the bootstrap content at module level so
|
||||
you're not re-reading and re-parsing `SKILL.md` on every call (#1202).
|
||||
- **Compaction.** If the harness compacts/summarizes history, re-inject
|
||||
afterward. pi sets an `injectBootstrap` flag on `session_start` and
|
||||
`session_compact`, clears it on `agent_end`, and inserts the message *after*
|
||||
any leading compaction-summary messages. OpenCode relies on its per-step
|
||||
re-injection plus the dedup guard.
|
||||
- **Message-object shape is per-harness — discover yours, don't copy a literal.**
|
||||
The two references use *incompatible* shapes: pi builds
|
||||
`{ role, content: [{ type, text }], timestamp }`; OpenCode manipulates
|
||||
`message.info.role` and `message.parts[]`. Find your harness's message shape
|
||||
from its API; copying a reference's object literal verbatim will fail silently.
|
||||
|
||||
**Shape C — point your extension's context file at the bootstrap; assemble
|
||||
nothing.** There is no injector, so you do *not* strip frontmatter or build a
|
||||
wrapped string. The context file your extension ships (declared by the manifest —
|
||||
*not* the user's own global file) pulls in two things: the `using-superpowers`
|
||||
skill and the harness's tool-mapping reference. `GEMINI.md`
|
||||
does this with two `@`-includes (`@./skills/using-superpowers/SKILL.md` and
|
||||
`@./skills/using-superpowers/references/<harness>-tools.md`); the harness loads
|
||||
them raw, frontmatter and all, and `SKILL.md` already carries its own
|
||||
`<EXTREMELY-IMPORTANT>` block internally. If your harness has no include syntax,
|
||||
inline the content into the instructions file instead. Gemini ships **no**
|
||||
"already loaded, don't re-invoke" preamble — for an `@`-include harness the
|
||||
content is the active instruction set, not a skill the model would re-load. If
|
||||
you find your harness does try to re-invoke, add that note as a literal line in
|
||||
the instructions file (you have no code to add it any other way).
|
||||
|
||||
### Step 4 — Write the tool mapping
|
||||
|
||||
Translate the action vocabulary into the harness's real tools. Cover every one
|
||||
of these actions (omit only what genuinely doesn't apply):
|
||||
|
||||
- read a file
|
||||
- create / edit / delete a file (one `apply_patch`-style tool, or separate
|
||||
write/edit?)
|
||||
- run a shell command
|
||||
- search file contents / find files by name (grep, glob)
|
||||
- fetch a URL / web search
|
||||
- **dispatch a subagent**, including how to pass the agent type — and any config
|
||||
flag needed to enable it
|
||||
- **create / update todos** (treat older `TodoWrite` references as this action)
|
||||
- **invoke a skill** — see Step 5
|
||||
|
||||
**Get the real tool names from the harness; never invent them.** If the docs
|
||||
don't list them, the authoritative source is the harness itself: in a live
|
||||
session, ask the model to "list the exact machine names of every tool you can
|
||||
call, one per line" and use what it reports.
|
||||
|
||||
**How the harness finds the `skills/` directory is itself per-harness** — confirm
|
||||
it, don't assume. Possibilities: a manifest `skills` path field (Codex's
|
||||
`"skills": "./skills/"`); a *co-located* `skills/` the harness auto-scans (where a
|
||||
path field is **ignored** — one real harness only scanned a `skills/` sitting next
|
||||
to `plugin.json`); an API/registration call (OpenCode, pi); or you stage an
|
||||
install dir that pairs the manifest with a **symlink to the repo's `skills/`** and
|
||||
point the installer at the staging dir (verify the installer *dereferences* the
|
||||
symlink and copies the real files — confirm with the harness's own
|
||||
`validate`/`install` command before relying on it). A `skills` path field is *not*
|
||||
portable.
|
||||
|
||||
Where the mapping lives depends on shape:
|
||||
|
||||
- **Shape A:** put it in `skills/using-superpowers/references/<harness>-tools.md`.
|
||||
The agent reaches it from the bootstrap — `SKILL.md`'s "Platform Adaptation"
|
||||
section links the per-harness references files. (Shape A harnesses have no
|
||||
instructions file; the mapping is *not* inlined into the hook output.)
|
||||
- **Shape B:** the mapping is typically inlined into the bootstrap string you
|
||||
inject (see the `toolMapping` constant in `superpowers.js`). pi keeps it in
|
||||
*both* places — `piToolMapping()` inline **and** `references/pi-tools.md`. If
|
||||
you maintain it in two places, update both, or the port is half-done.
|
||||
- **Shape C:** put it in `references/<harness>-tools.md` and pull it into the
|
||||
always-loaded instructions file (e.g. `GEMINI.md` `@`-includes
|
||||
`gemini-tools.md`).
|
||||
|
||||
You may also add a one-line pointer to your harness in `SKILL.md`'s "Platform
|
||||
Adaptation" section so an agent reading the bootstrap knows where its mapping
|
||||
lives. This is the one edit to a `SKILL.md` a port may make — and only because
|
||||
that section is a pointer list, not behavior-shaping content. It does not violate
|
||||
the "don't edit skill bodies" rule (Part 1); do not touch anything else in any
|
||||
skill. (The list is a convenience pointer, not an exhaustive registry — not every
|
||||
harness is listed.)
|
||||
|
||||
### Step 5 — Handle a harness with no native skill tool
|
||||
|
||||
`using-superpowers/SKILL.md` tells the model to *never read skill files manually
|
||||
with file tools — always use your platform's skill-loading mechanism.* The point
|
||||
is "don't bypass the mechanism," not "never use file-read." What counts as "your
|
||||
platform's mechanism" depends on the harness — and for a harness with no skill
|
||||
tool, the documented mechanism *is* reading `SKILL.md`. So reading it there
|
||||
honors the rule rather than breaking it. Distinguish three cases:
|
||||
|
||||
1. **Native `Skill`-style tool** (Claude Code, Copilot CLI, Gemini's
|
||||
`activate_skill`): point the mapping at that tool.
|
||||
2. **Native skill *discovery* but no `Skill` tool** (pi, Antigravity): the harness
|
||||
can find and list skills, but the model can't call a tool to load one. Get the
|
||||
skills installed where the harness scans (pi registers via `resources_discover`
|
||||
→ `skillPaths`; OpenCode via its `config` hook; `agy plugin install` copies
|
||||
them in), and tell the model to load a skill by **reading its `SKILL.md` with
|
||||
the file-read tool when the skill applies** — the sanctioned mechanism here,
|
||||
the way `references/pi-tools.md` states it.
|
||||
|
||||
**For the bootstrap itself, first check for an inherited hook (Part 2).** If the
|
||||
harness runs the existing Claude Code plugin's `SessionStart` hook — Antigravity
|
||||
does; test it before assuming otherwise — the bootstrap is already delivered and
|
||||
you ship nothing. Only if it doesn't, and the harness has a `contextFileName`-style
|
||||
manifest field, point that field at the real `using-superpowers/SKILL.md` (or a
|
||||
tiny committed `@`-include file like `GEMINI.md`). **Never generate a wrapped copy
|
||||
of the bootstrap at install time:** a generated copy needs an installer and drifts
|
||||
from source. You get to name the file, so name the real one.
|
||||
|
||||
**Fallback — the surfaced skill index.** If there's no context-file field but
|
||||
the harness surfaces each installed skill's name + description at session start,
|
||||
you need *neither* a built index nor a runtime-list instruction — the harness
|
||||
is the index, and `using-superpowers`'s own surfaced description can be what
|
||||
triggers the model to load it. This is softer than a declared context file;
|
||||
two things it does **not** give you, versus a context file / hook / in-process
|
||||
injector — account for both:
|
||||
- **It bootstraps *triggering*, not the *tool mapping*.** An injector prepends
|
||||
`<harness>-tools.md` alongside `using-superpowers` every session. Here nothing
|
||||
injects the mapping — the model only sees skill *descriptions* and must *read*
|
||||
your `references/<harness>-tools.md` when it needs tool names. It works
|
||||
because skills name actions (the model reads the mapping when it acts), but
|
||||
it's softer than injection. Make sure the mapping is reachable from what the
|
||||
model loads — e.g. linked from `SKILL.md`'s Platform Adaptation section and
|
||||
installed alongside the skills — not just sitting in the repo.
|
||||
- **There's no structural guarantee the trigger fires.** No `<EXTREMELY_IMPORTANT>`
|
||||
wrapper, no dedup, no re-injection after compaction — firing depends on the
|
||||
model choosing to act on a description it sees in the index. This is exactly
|
||||
why the acceptance test is mandatory here: it is the *only* guarantee, so run
|
||||
it on the model(s) your users will actually use, not just the strongest one.
|
||||
3. **No skill system at all:** there is nothing to register, and the *only*
|
||||
mechanism is the model reading `SKILL.md` on demand. But the model can't read
|
||||
what it can't find: `using-superpowers/SKILL.md` does **not** enumerate the
|
||||
available skills, so on its own the model won't know which skills exist or
|
||||
their triggers. You must supply a discovery path. Two options, and they differ
|
||||
in durability: (a) generate a skill index (each `skills/*/SKILL.md`'s `name` +
|
||||
`description` frontmatter) and place it *inside* the `<EXTREMELY_IMPORTANT>`
|
||||
wrapper alongside the tool mapping (Shape B recipe above) so it's covered by
|
||||
the dedup guard — but a build-time index goes stale as skills are added; or
|
||||
(b) instruct the model to list `skills/*/SKILL.md` at runtime and read their
|
||||
frontmatter to find a match — slower but never stale. Prefer (b) unless you
|
||||
have a reason not to. Without either, a no-skill-system port loads the
|
||||
bootstrap but silently never triggers any other skill.
|
||||
|
||||
In cases 2 and 3, say plainly in your tool mapping that reading `SKILL.md` is the
|
||||
blessed path, so the model doesn't think it's violating the "never read skill
|
||||
files" rule. Don't go hunting for a `skillPaths`-style registration API in a
|
||||
harness that has no skill system — case 3 has none.
|
||||
|
||||
### Step 6 — Add tests
|
||||
|
||||
Match the existing per-harness test style:
|
||||
|
||||
- **Shape A:** assert the hook's stdout has the exact JSON shape your harness
|
||||
consumes, and that it contains the bootstrap. See `tests/hooks/test-session-start.sh`,
|
||||
which validates each harness's output shape.
|
||||
- **Shape B:** a unit test that fakes the harness's plugin API and asserts the
|
||||
lifecycle handlers register, the bootstrap injects once, the dedup guard
|
||||
works, and (if relevant) compaction re-injection works. See
|
||||
`tests/pi/test-pi-extension.mjs`. Add an isolated-install integration check in
|
||||
the style of `tests/opencode/`.
|
||||
- If the bootstrap is cached, test that the cache behaves when the file is
|
||||
missing (see the OpenCode caching tests).
|
||||
|
||||
These automated tests cover the wiring; the live tmux run in Step 7 is what
|
||||
proves the integration actually triggers skills.
|
||||
|
||||
### Step 7 — Install locally, then drive a live instance to verify
|
||||
|
||||
You cannot confirm a port works by reading code. You have to run the harness with
|
||||
your in-progress port loaded and watch a real session — which is also how you
|
||||
produce the transcript the PR requires.
|
||||
|
||||
**Install locally.** Point a *local* instance of the harness at your working
|
||||
tree, not a published build:
|
||||
|
||||
- **Shape A / C:** install the plugin/extension from this repo's local path (or
|
||||
symlink its directory into wherever the harness looks). Find the harness's
|
||||
"install from a local directory / git checkout" path in its docs.
|
||||
- **Shape B:** register the local module — e.g. an `opencode.json` `plugin`
|
||||
entry pointing at the local path, or pi resolving the `package.json` fields
|
||||
from the repo.
|
||||
|
||||
Reinstall after each change and restart the harness, since the bootstrap loads at
|
||||
startup.
|
||||
|
||||
**Drive it with tmux.** Most harnesses are interactive REPLs/TUIs that can't be
|
||||
driven by piping stdin, so run the harness inside a detached tmux session and
|
||||
control it with `send-keys` / `capture-pane`. A harness may advertise a
|
||||
non-interactive "run one prompt" mode (e.g. `opencode run "..."`) — try it for the
|
||||
quick smoke check, but **don't depend on it**: these modes are frequently flaky,
|
||||
auth-gated, or trust-gated (one real harness's `--print` mode hung and timed out
|
||||
with no output every time). Be ready to do *everything*, including the smoke
|
||||
check, through tmux.
|
||||
|
||||
**Clear the gates first, or tmux stalls silently.** Many harnesses block on
|
||||
first-run onboarding, a "do you trust this folder?" prompt, a sandbox mode, or a
|
||||
permission gate — and a detached tmux session will just sit there with no error
|
||||
while it waits. Before the run, pre-trust your scratch directory (in the harness's
|
||||
settings/config) or be prepared to answer those prompts via `send-keys`, and
|
||||
account for the harness's startup time in your first `sleep`.
|
||||
|
||||
```bash
|
||||
# 1. Launch the harness detached, in a throwaway project dir
|
||||
mkdir -p /tmp/port-smoke
|
||||
tmux new-session -d -s port-test -c /tmp/port-smoke '<harness-launch-command>'
|
||||
|
||||
# 2. Let it initialize — real TUIs take longer than you think (10s+ with a model
|
||||
# handshake); tune this. THEN capture and clear any blocking modal before you
|
||||
# type a prompt: first-run onboarding and "trust this folder?" are modal, so
|
||||
# keystrokes sent during them select menu items instead of typing your prompt.
|
||||
sleep 12
|
||||
tmux capture-pane -t port-test -p # onboarding / trust prompt? answer it via send-keys first
|
||||
# (e.g. tmux send-keys -t port-test Enter # to accept a trust prompt — inspect before assuming)
|
||||
|
||||
# 3. Smoke check: does the model know it has superpowers?
|
||||
# Send the text and Enter as SEPARATE send-keys with a beat between them —
|
||||
# sending them together races on some TUIs (Enter arrives before the text lands).
|
||||
tmux send-keys -t port-test 'What are your superpowers?'; sleep 0.4; tmux send-keys -t port-test Enter
|
||||
sleep 5
|
||||
tmux capture-pane -t port-test -p # reply should show it knows its skills
|
||||
|
||||
# 4. Acceptance test: exact prompt (note the escaped apostrophe), fresh session
|
||||
tmux send-keys -t port-test 'Let'\''s make a react todo list'; sleep 0.4; tmux send-keys -t port-test Enter
|
||||
# poll until the turn finishes — re-capture every few seconds, don't capture once
|
||||
sleep 8
|
||||
tmux capture-pane -t port-test -p # PASS = brainstorming triggers BEFORE any code
|
||||
|
||||
# 5. Save the transcript for the PR, then clean up
|
||||
tmux capture-pane -t port-test -p > /tmp/port-smoke/transcript.txt
|
||||
tmux kill-session -t port-test
|
||||
```
|
||||
|
||||
tmux gotchas that bite here: wait after launch before the first capture; send the
|
||||
prompt text and `Enter` as *separate* `send-keys` calls with a short `sleep`
|
||||
between them (sending them together races on some TUIs), and `Enter` is a key name
|
||||
not `\n`; the agent's turn takes time, so **poll `capture-pane` in a loop** rather
|
||||
than capturing once; `capture-pane` shows only the visible pane, so for a long
|
||||
conversation use the harness's own transcript/log file as the record of truth;
|
||||
always `kill-session` when done.
|
||||
|
||||
If the smoke check shows the model *doesn't* know it has superpowers, the
|
||||
bootstrap isn't loading — fix that before bothering with the acceptance test.
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Distribution and release
|
||||
|
||||
A working integration in this repo isn't usable until a real user can install
|
||||
it. Distribution differs per harness ecosystem — find yours:
|
||||
|
||||
| Channel | Example | What you do |
|
||||
|---|---|---|
|
||||
| Native plugin marketplace | Claude Code | Register in `.claude-plugin/marketplace.json`; users `/plugin install`. The external `superpowers-marketplace` repo is the source of truth users install from — see the release steps in `CLAUDE.md`. |
|
||||
| External marketplace fork, synced by script | Codex | `scripts/sync-to-codex-plugin.sh` rsyncs the tracked plugin files into a separate fork repo and opens a PR. Read its include/exclude list so you ship the right tree (it deliberately drops repo-internal dirs and other harnesses' dotdirs). |
|
||||
| Git-URL extension install | Gemini, OpenCode | Users install from a git URL (`gemini extensions install …`; an `opencode.json` `plugin` array entry). Document the exact command. |
|
||||
| Package-manifest fields | pi | Declared through fields in the repo-root `package.json`; users install via the harness's package command. |
|
||||
| Existing plugin, installed directly | Antigravity (`agy`), Factory Droid | The harness installs another harness's committed plugin from a git URL — `agy plugin install <repo-url>` imports the Claude Code `skills/` + `hooks/` and runs the `SessionStart` hook. No installer script, no new manifest: document the one-line install command. |
|
||||
|
||||
Then:
|
||||
|
||||
- **A plugin installer may silently strip *undeclared* files — so make the
|
||||
bootstrap a file the installer *recognizes*, never a user-config edit.** A
|
||||
`plugin install` typically copies only the components it knows about
|
||||
(skills/agents/commands/mcp/hooks/context) and discards anything else, so a
|
||||
file the manifest doesn't declare just vanishes from the install. The fix is
|
||||
**not** to give up and write into the user's config (**rule 2**) — it's to make
|
||||
the bootstrap a recognized component. In escalation order:
|
||||
- **Check for an inherited hook first.** If `plugin install` imports an existing
|
||||
Claude Code plugin's `hooks/` and the harness runs the `SessionStart` hook,
|
||||
the bootstrap is already delivered and you ship nothing. Antigravity does
|
||||
exactly this — `agy plugin install <repo-url>` brings in the skills and the
|
||||
hook, and a clean session loads `using-superpowers`, triggers `brainstorming`,
|
||||
and enters the brainstorming flow before any code. Verify with the acceptance
|
||||
test. This is the cheapest and most robust path; rule it out before the rest.
|
||||
- **Otherwise, if the manifest declares a context file, point it at the real
|
||||
skill.** A `contextFileName`-style field names a file the installer preserves
|
||||
and the harness loads every session. Point it straight at
|
||||
`using-superpowers/SKILL.md`, or at a tiny committed `@`-include file like
|
||||
`GEMINI.md` if the harness expands includes (prove the expansion — Shape C
|
||||
caveat). **Do not generate a wrapped copy of the bootstrap at install time:**
|
||||
that needs an installer and can drift from source. You get to name the file,
|
||||
so name the real one. **Verify with a marker** that the installer keeps the
|
||||
file and the harness loads it — an undeclared file is stripped as unrecognized.
|
||||
- **Otherwise lean on the installed `using-superpowers` skill itself.** If the
|
||||
harness surfaces each installed skill's name + description at session start,
|
||||
the `using-superpowers` description ("Use when starting any conversation…")
|
||||
can prompt the model to load it — installing the skill *is* the bootstrap.
|
||||
Softer (no guaranteed wrapper; it carries triggering but not the tool mapping
|
||||
— see Step 5), so prefer an inherited hook or a declared context file when
|
||||
available.
|
||||
- If none works, the harness cannot be cleanly supported yet — **say so**
|
||||
and raise it, rather than hand-editing the user's config.
|
||||
|
||||
- **Write install docs.** A `docs/README.<harness>.md` and/or a
|
||||
`.<harness>/INSTALL.md` (see `docs/README.opencode.md` and
|
||||
`.opencode/INSTALL.md`), plus an install section in the top-level `README.md`.
|
||||
The only supported install action is **running the harness's own install
|
||||
command** (`agy plugin install`, `gemini extensions install`, `/plugin
|
||||
install`, etc.). Hand-copying skill files and editing the user's global/personal
|
||||
config are *both* off-limits (rule 2 / the PR rules). If the harness has no
|
||||
install command at all — its only surface is a user-owned config file — then it
|
||||
fails the "deliver via install mechanism" rule, and you should raise that rather
|
||||
than ship an installer that edits the user's files.
|
||||
- **Register the version.** If your harness introduces a *new* versioned
|
||||
manifest, add its path and version field to `.version-bump.json` so
|
||||
`scripts/bump-version.sh` keeps it in lockstep (read that file to see what's
|
||||
currently tracked). A new manifest that isn't registered there will ship a
|
||||
stale version. If your harness instead rides an already-tracked file — pi
|
||||
declares itself in the repo-root `package.json`, which is already listed —
|
||||
there's nothing new to add.
|
||||
- **If no existing channel fits, you're standing up a new one.** None of the four
|
||||
rows may match your harness. If it needs a Codex-style external fork sync,
|
||||
`scripts/sync-to-codex-plugin.sh` is the template to clone (note its anchored
|
||||
include/exclude list and its PR automation). And whenever you add a new
|
||||
per-harness directory, add it to the *other* harnesses' sync excludes (e.g. the
|
||||
EXCLUDES list in `sync-to-codex-plugin.sh`) so your dotdir doesn't leak into
|
||||
their distributions.
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — Cross-platform / Windows
|
||||
|
||||
Only relevant to the shell-hook shape. `hooks/run-hook.cmd` is a polyglot: a
|
||||
single file that's valid as both a Windows batch script and a Unix shell script.
|
||||
On Windows, `cmd.exe` runs the batch portion, which locates `bash` (Git for
|
||||
Windows, then `bash` on PATH) and runs the named hook script; if no bash is
|
||||
found it exits cleanly so the harness still works, just without injection. On
|
||||
Unix, the leading `:` makes the batch block a no-op and the shell runs the
|
||||
script directly.
|
||||
|
||||
Two rules this enforces, which you must respect:
|
||||
|
||||
- **Hook scripts are extensionless** (`session-start`, not `session-start.sh`).
|
||||
Claude Code's Windows handling prepends `bash` to any command containing
|
||||
`.sh`, which would double-invoke. Name your hook script without an extension.
|
||||
- Don't write per-OS variants of the hook script. One extensionless bash script
|
||||
plus the polyglot wrapper covers all three platforms.
|
||||
|
||||
`hooks/run-hook.cmd` itself is the authoritative implementation — read it.
|
||||
(`docs/windows/polyglot-hooks.md` covers the background and rationale but
|
||||
describes an earlier per-script `.cmd`/`.sh` variant, so trust the code over that
|
||||
doc where they differ.)
|
||||
|
||||
---
|
||||
|
||||
## Part 8 — Submitting the PR
|
||||
|
||||
- Target the **`dev`** branch. One harness per PR.
|
||||
- Fill in the PR template's **"New harness support"** section and paste the
|
||||
complete acceptance-test transcript (the "Let's make a react todo list"
|
||||
session showing `brainstorming` auto-triggering). A PR without this proof will
|
||||
be closed.
|
||||
- Superpowers is a zero-dependency plugin. Don't add a third-party runtime
|
||||
dependency. Adding a new harness is the one carve-out the contributor rules
|
||||
allow, and even then keep it to what the integration strictly requires —
|
||||
type-only imports that compile away are fine; runtime packages are not.
|
||||
- Don't touch skill bodies (Part 1). If you found yourself editing a `SKILL.md`
|
||||
to make the port work, the fix belongs in your tool mapping instead.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Reference integrations (current)
|
||||
|
||||
Use this as the live index; when in doubt, read the files, not this table.
|
||||
|
||||
| Harness | Entry point | Bootstrap mechanism | Tool mapping | Tests | Distribution |
|
||||
|---|---|---|---|---|---|
|
||||
| Claude Code | `.claude-plugin/plugin.json` + `hooks/hooks.json` | shell hook → `hooks/session-start` (`hookSpecificOutput.additionalContext`) | native `Skill` tool; `references/claude-code-tools.md` | `tests/hooks/` | marketplace |
|
||||
| Antigravity (`agy`) | none — installs the Claude Code plugin | `agy plugin install <repo-url>` imports `skills/` + `hooks/` and runs the Claude Code `SessionStart` hook | no `Skill` tool; load via `view_file` on `SKILL.md`; `references/antigravity-tools.md` | `tests/antigravity/` | `agy plugin install <git-url>` (README) |
|
||||
| Codex | `.codex-plugin/plugin.json` + `hooks/hooks-codex.json` | shell hook → `hooks/session-start-codex` | `references/codex-tools.md` | `tests/codex-plugin-sync/`, `tests/hooks/` | fork sync (`scripts/sync-to-codex-plugin.sh`) |
|
||||
| Cursor | `.cursor-plugin/plugin.json` + `hooks/hooks-cursor.json` | shell hook → `hooks/session-start` (`additional_context`) | `references/claude-code-tools.md` | `tests/hooks/` | hand-authored |
|
||||
| Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | `references/copilot-tools.md` | `tests/hooks/` | — |
|
||||
| Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` |
|
||||
| OpenCode | `.opencode/plugins/superpowers.js` (declared via root `package.json` `main`) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` plugin git URL |
|
||||
| pi | `.pi/extensions/superpowers.ts` | in-process: `resources_discover` registers skills; `context` event injects user message; lifecycle-flag + compaction-aware | `piToolMapping()` inline **and** `references/pi-tools.md` | `tests/pi/` | repo-root `package.json` fields |
|
||||
|
||||
## Appendix B — Gotchas that have bitten porters
|
||||
|
||||
- **Opt-in isn't a port.** If your human partner has to do anything per session
|
||||
to get Superpowers, the acceptance test fails. Re-read Part 2.
|
||||
- **Assuming a derived harness lacks the parent's hook.** Antigravity runs the
|
||||
Claude Code `SessionStart` hook; we nearly shipped a whole context-file scaffold
|
||||
on the false premise that it didn't. Install the existing plugin and run the
|
||||
acceptance test before building any bootstrap mechanism (Part 2).
|
||||
- **Generating a wrapped bootstrap copy at install time.** If a manifest's
|
||||
`contextFileName` lets you name the file, point it at the real
|
||||
`using-superpowers/SKILL.md` (or a committed `@`-include file). A generated copy
|
||||
needs an installer and drifts from source (Part 6).
|
||||
- **Wrong JSON field → silent failure or double injection.** Shape A only.
|
||||
Confirm the exact field/nesting; Claude Code reads two fields without dedup.
|
||||
- **Hook-config schema varies per harness.** Shape A. Cursor's `hooks-cursor.json`
|
||||
looks nothing like the Claude/Codex one (`version`, lowercase `sessionStart`,
|
||||
relative command, no `matcher`/`type`/`async`). Match the closest existing file.
|
||||
- **Plugin-root env var differs per harness.** Shape A. The hook command uses
|
||||
`${CLAUDE_PLUGIN_ROOT}` (Claude), `${PLUGIN_ROOT}` (Codex), or a relative path
|
||||
(Cursor). Use what your harness exports; the script re-derives the root itself.
|
||||
- **System-message injection.** Shape B injects a *user* message on purpose
|
||||
(#750, #894). Don't "fix" it to a system message.
|
||||
- **Per-step vs per-turn callbacks.** OpenCode fires every step (per-call dedup
|
||||
guard); pi fires per turn (lifecycle flag + `agent_end` reset). Copying one
|
||||
harness's dedup strategy onto the other's callback frequency breaks injection.
|
||||
- **Message-object shape is per-harness.** Shape B. pi and OpenCode use
|
||||
incompatible shapes; discover yours, don't copy a reference's object literal.
|
||||
- **Hunting for a skill-registration API that doesn't exist.** A harness with no
|
||||
skill system (not just no `Skill` tool) has nothing to register — the model
|
||||
reads `SKILL.md` on demand. Don't assume a `skillPaths` equivalent exists.
|
||||
- **Mapping in two places.** For in-process plugins the mapping may live both
|
||||
inline and in a `references/` file (pi). Update both.
|
||||
- **The "never read skill files" line.** It means "don't bypass your platform's
|
||||
skill-loading mechanism," not "never use file-read." On a no-skill-tool harness
|
||||
that mechanism *is* reading `SKILL.md` — say so explicitly in the mapping
|
||||
(Part 5).
|
||||
- **`.sh` on Windows.** Keep hook scripts extensionless (Part 7).
|
||||
- **Unregistered version.** A new manifest not added to `.version-bump.json`
|
||||
ships stale (Part 6).
|
||||
- **Editing skills to fit the harness.** Never. The fix goes in the tool mapping.
|
||||
989
docs/superpowers/plans/2026-05-08-visual-companion-alpine.md
Normal file
989
docs/superpowers/plans/2026-05-08-visual-companion-alpine.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -53,7 +53,6 @@ EXCLUDES=(
|
||||
"/.github/"
|
||||
"/.gitignore"
|
||||
"/.opencode/"
|
||||
"/.pi/"
|
||||
"/.version-bump.json"
|
||||
"/.worktrees/"
|
||||
".DS_Store"
|
||||
@@ -416,6 +415,53 @@ 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).
|
||||
@@ -425,7 +471,7 @@ Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstre
|
||||
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).
|
||||
@@ -435,7 +481,7 @@ Copies the tracked plugin files from upstream, including the committed Codex man
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md
vendored
Normal file
48
skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md
vendored
Normal 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.
|
||||
```
|
||||
5
skills/brainstorming/scripts/vendor/alpine.js
vendored
Normal file
5
skills/brainstorming/scripts/vendor/alpine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
skills/brainstorming/scripts/vendor/alpine.provenance.json
vendored
Normal file
12
skills/brainstorming/scripts/vendor/alpine.provenance.json
vendored
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---');
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ write_upstream_fixture() {
|
||||
"$repo/evals/drill" \
|
||||
"$repo/hooks" \
|
||||
"$repo/scripts" \
|
||||
"$repo/skills/brainstorming/scripts/vendor" \
|
||||
"$repo/skills/example"
|
||||
|
||||
if [[ "$with_pure_ignored" == "1" ]]; then
|
||||
@@ -257,6 +258,30 @@ 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"
|
||||
@@ -277,6 +302,12 @@ EOF
|
||||
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
|
||||
|
||||
@@ -333,6 +364,7 @@ write_synced_destination_fixture() {
|
||||
"$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"
|
||||
|
||||
@@ -387,6 +419,30 @@ 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'
|
||||
@@ -405,6 +461,12 @@ EOF
|
||||
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
|
||||
@@ -423,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"
|
||||
|
||||
@@ -436,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
|
||||
@@ -484,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"
|
||||
@@ -509,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
|
||||
@@ -528,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 ==="
|
||||
|
||||
@@ -547,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"
|
||||
@@ -577,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"
|
||||
@@ -585,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
|
||||
@@ -600,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")"
|
||||
@@ -610,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..."
|
||||
@@ -631,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"
|
||||
@@ -665,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"
|
||||
@@ -672,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..."
|
||||
@@ -687,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 ""
|
||||
|
||||
Reference in New Issue
Block a user