diff --git a/docs/superpowers/plans/2026-05-08-visual-companion-alpine.md b/docs/superpowers/plans/2026-05-08-visual-companion-alpine.md new file mode 100644 index 00000000..9785f704 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-visual-companion-alpine.md @@ -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(''), '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 = '
Details
'; + 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 ``: + +```html + +``` + +Change the indicator copy to: + +```html + Interact with the mockup, then return to the terminal +``` + +- [ ] **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' + +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 +
+
+ + +
+ +
+

Week plan

+

Three realistic meals are enough for the mockup.

+
+ +
+

Grocery list

+ + + +
+
+``` + +Rules: + +- Write content fragments by default; do not add an Alpine `` 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/` 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=` 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/` 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. diff --git a/scripts/sync-to-codex-plugin.sh b/scripts/sync-to-codex-plugin.sh index 8c91b4cd..f4908365 100755 --- a/scripts/sync-to-codex-plugin.sh +++ b/scripts/sync-to-codex-plugin.sh @@ -415,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). @@ -424,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). @@ -434,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 diff --git a/skills/brainstorming/scripts/frame-template.html b/skills/brainstorming/scripts/frame-template.html index 6325ef91..7a3c8ec8 100644 --- a/skills/brainstorming/scripts/frame-template.html +++ b/skills/brainstorming/scripts/frame-template.html @@ -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%; } +
@@ -207,7 +208,7 @@
- Click an option above, then return to the terminal + Interact with the mockup, then return to the terminal
diff --git a/skills/brainstorming/scripts/helper.js b/skills/brainstorming/scripts/helper.js index 111f97f5..c804e752 100644 --- a/skills/brainstorming/scripts/helper.js +++ b/skills/brainstorming/scripts/helper.js @@ -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 = '' + label + ' selected — return to terminal to continue'; diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index 562c17f8..9af74ada 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -101,6 +101,26 @@ h1 { color: #333; } p { color: #666; } 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 = ''; +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); diff --git a/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md b/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..a53a2eff --- /dev/null +++ b/skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md @@ -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. +``` diff --git a/skills/brainstorming/scripts/vendor/alpine.js b/skills/brainstorming/scripts/vendor/alpine.js new file mode 100644 index 00000000..ab371ef4 --- /dev/null +++ b/skills/brainstorming/scripts/vendor/alpine.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `'), '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 = '
Details
'; + 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 ---'); diff --git a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh index a94cdecf..83c552fd 100755 --- a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh +++ b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh @@ -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' + +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' + +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 ""