`. Please review it and let me know if you want to make any changes before we start writing out the implementation plan."
-
-Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves.
-
-**Implementation:**
-
-- Invoke the writing-plans skill to create a detailed implementation plan
-- Do NOT invoke any other skill. writing-plans is the next step.
-
-## Key Principles
-
-- **One question at a time** - Don't overwhelm with multiple questions
-- **Multiple choice preferred** - Easier to answer than open-ended when possible
-- **YAGNI ruthlessly** - Remove unnecessary features from all designs
-- **Explore alternatives** - Always propose 2-3 approaches before settling
-- **Incremental validation** - Present design, get approval before moving on
-- **Be flexible** - Go back and clarify when something doesn't make sense
-
-## Visual Companion
-
-A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser.
-
-**Offering the companion (just-in-time):** Do NOT offer it upfront. Wait until a question would genuinely be clearer shown than told — a real mockup / layout / diagram question, not merely a UI *topic*. The first time that happens, offer it then, as its own message:
-> "This next part might be easier if I show you — I can put together mockups, diagrams, and comparisons in a browser tab as we go. It's still new and can be token-intensive. Want me to? I'll open it for you."
-
-**This offer MUST be its own message.** Only the offer — no clarifying question, summary, or other content. Wait for the user's response. If they accept, start the server with `--open` so their browser opens to the first screen automatically. If they decline, continue text-only and don't offer again unless they raise it.
-
-**Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?**
-
-- **Use the browser** for content that IS visual — mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs
-- **Use the terminal** for content that is text — requirements questions, conceptual choices, tradeoff lists, A/B/C/D text options, scope decisions
-
-A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser.
-
-If they agree to the companion, read the detailed guide before proceeding:
-`skills/brainstorming/visual-companion.md`
diff --git a/.claude/skills/brainstorming/scripts/frame-template.html b/.claude/skills/brainstorming/scripts/frame-template.html
deleted file mode 100644
index f540bb8..0000000
--- a/.claude/skills/brainstorming/scripts/frame-template.html
+++ /dev/null
@@ -1,213 +0,0 @@
-
-
-
-
- Superpowers Brainstorming
-
-
-
-
-
-
-
-
-
diff --git a/.claude/skills/brainstorming/scripts/helper.js b/.claude/skills/brainstorming/scripts/helper.js
deleted file mode 100644
index e11d264..0000000
--- a/.claude/skills/brainstorming/scripts/helper.js
+++ /dev/null
@@ -1,167 +0,0 @@
-(function() {
- const MIN_RECONNECT_MS = 500;
- const MAX_RECONNECT_MS = 30000;
- const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected
-
- // Pure: next backoff delay (doubles, capped). Exported for unit tests.
- function nextReconnectDelay(current, max) {
- return Math.min(current * 2, max);
- }
- if (typeof module !== 'undefined' && module.exports) {
- module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS };
- }
-
- // Everything below is browser-only; bail out when loaded in Node (tests).
- if (typeof window === 'undefined') return;
-
- let ws = null;
- let eventQueue = [];
- let reconnectDelay = MIN_RECONNECT_MS;
- let reconnectTimer = null;
- let disconnectedSince = null;
- let everConnected = false;
- let tombstoneShown = false;
-
- function sessionKey() {
- try {
- return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
- } catch (e) {}
- return null;
- }
-
- function websocketUrl() {
- const key = sessionKey();
- return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
- }
-
- function reloadAfterRecovery() {
- const key = sessionKey();
- if (key) {
- window.location.replace('/?key=' + encodeURIComponent(key));
- } else {
- window.location.reload();
- }
- }
-
- // Reflect connection state in the frame's status pill (absent on full-doc screens).
- function setStatus(state) {
- const el = document.querySelector('.status');
- if (!el) return;
- const map = {
- connecting: ['Connecting…', 'var(--text-tertiary)'],
- connected: ['Connected', 'var(--success)'],
- reconnecting: ['Reconnecting…', 'var(--warning)'],
- disconnected: ['Disconnected', 'var(--error)']
- };
- const [text, color] = map[state] || map.disconnected;
- el.textContent = text;
- el.style.setProperty('--status-color', color);
- }
-
- // Self-styled so it works on framed and full-document screens alike.
- function showTombstone() {
- if (tombstoneShown) return;
- tombstoneShown = true;
- const el = document.createElement('div');
- el.id = 'bs-tombstone';
- el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' +
- 'align-items:center;justify-content:center;padding:2rem;text-align:center;' +
- 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif';
- el.innerHTML = '' +
- '
Companion paused
' +
- '
This brainstorm companion has stopped. ' +
- 'Ask your coding agent to bring it back — this page reconnects automatically.
';
- if (document.body) document.body.appendChild(el);
- }
-
- function connect() {
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
- setStatus(everConnected ? 'reconnecting' : 'connecting');
- ws = new WebSocket(websocketUrl());
-
- ws.onopen = () => {
- const recovered = tombstoneShown;
- everConnected = true;
- disconnectedSince = null;
- reconnectDelay = MIN_RECONNECT_MS;
- tombstoneShown = false;
- setStatus('connected');
- eventQueue.forEach(e => ws.send(JSON.stringify(e)));
- eventQueue = [];
- // Recovered from a tombstoned outage (e.g. the server restarted on the same
- // port) — reload through the keyed bootstrap when possible so the cookie is
- // refreshed before the visible URL returns to bare /.
- if (recovered) reloadAfterRecovery();
- };
-
- ws.onmessage = (msg) => {
- let data;
- try { data = JSON.parse(msg.data); } catch (e) { return; }
- if (data.type === 'reload') window.location.reload();
- };
-
- ws.onclose = () => {
- ws = null;
- if (disconnectedSince === null) disconnectedSince = Date.now();
- if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) {
- setStatus('disconnected');
- showTombstone();
- } else {
- setStatus('reconnecting');
- }
- reconnectTimer = setTimeout(connect, reconnectDelay);
- reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS);
- };
-
- // Let onclose own reconnection so we don't schedule it twice.
- ws.onerror = () => { try { ws.close(); } catch (e) {} };
- }
-
- function sendEvent(event) {
- event.timestamp = Date.now();
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(event));
- } else {
- eventQueue.push(event);
- }
- }
-
- // Capture clicks on choice elements
- document.addEventListener('click', (e) => {
- const target = e.target.closest('[data-choice]');
- if (!target) return;
-
- sendEvent({
- type: 'click',
- text: target.textContent.trim(),
- choice: target.dataset.choice,
- id: target.id || null
- });
-
- });
-
- // Frame UI: selection tracking
- window.selectedChoice = null;
-
- window.toggleSelect = function(el) {
- const container = el.closest('.options') || el.closest('.cards');
- const multi = container && container.dataset.multiselect !== undefined;
- if (container && !multi) {
- container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
- }
- if (multi) {
- el.classList.toggle('selected');
- } else {
- el.classList.add('selected');
- }
- window.selectedChoice = el.dataset.choice;
- };
-
- // Expose API for explicit use
- window.brainstorm = {
- send: sendEvent,
- choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
- };
-
- connect();
-})();
diff --git a/.claude/skills/brainstorming/scripts/server.cjs b/.claude/skills/brainstorming/scripts/server.cjs
deleted file mode 100644
index a828b35..0000000
--- a/.claude/skills/brainstorming/scripts/server.cjs
+++ /dev/null
@@ -1,723 +0,0 @@
-const crypto = require('crypto');
-const http = require('http');
-const fs = require('fs');
-const path = require('path');
-
-// ========== WebSocket Protocol (RFC 6455) ==========
-
-const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
-const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
-const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024;
-
-function computeAcceptKey(clientKey) {
- return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
-}
-
-function encodeFrame(opcode, payload) {
- const fin = 0x80;
- const len = payload.length;
- let header;
-
- if (len < 126) {
- header = Buffer.alloc(2);
- header[0] = fin | opcode;
- header[1] = len;
- } else if (len < 65536) {
- header = Buffer.alloc(4);
- header[0] = fin | opcode;
- header[1] = 126;
- header.writeUInt16BE(len, 2);
- } else {
- header = Buffer.alloc(10);
- header[0] = fin | opcode;
- header[1] = 127;
- header.writeBigUInt64BE(BigInt(len), 2);
- }
-
- return Buffer.concat([header, payload]);
-}
-
-function decodeFrame(buffer) {
- if (buffer.length < 2) return null;
-
- const secondByte = buffer[1];
- const opcode = buffer[0] & 0x0F;
- const masked = (secondByte & 0x80) !== 0;
- let payloadLen = secondByte & 0x7F;
- let offset = 2;
-
- if (!masked) throw new Error('Client frames must be masked');
-
- if (payloadLen === 126) {
- if (buffer.length < 4) return null;
- payloadLen = buffer.readUInt16BE(2);
- offset = 4;
- } else if (payloadLen === 127) {
- if (buffer.length < 10) return null;
- const extendedLen = buffer.readBigUInt64BE(2);
- if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) {
- throw new Error('WebSocket frame payload exceeds maximum allowed size');
- }
- payloadLen = Number(extendedLen);
- offset = 10;
- }
-
- if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) {
- throw new Error('WebSocket frame payload exceeds maximum allowed size');
- }
-
- const maskOffset = offset;
- const dataOffset = offset + 4;
- const totalLen = dataOffset + payloadLen;
- if (buffer.length < totalLen) return null;
-
- const mask = buffer.slice(maskOffset, dataOffset);
- const data = Buffer.alloc(payloadLen);
- for (let i = 0; i < payloadLen; i++) {
- data[i] = buffer[dataOffset + i] ^ mask[i % 4];
- }
-
- return { opcode, payload: data, bytesConsumed: totalLen };
-}
-
-// ========== Configuration ==========
-
-const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null;
-const randomPort = () => 49152 + Math.floor(Math.random() * 16383);
-// Prefer an explicit port, else the port this session last bound (so a restart
-// reuses it and an already-open browser tab reconnects), else a random high port.
-function preferredPort() {
- if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT);
- if (PORT_FILE) {
- try {
- const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim());
- if (Number.isInteger(p) && p > 1023 && p < 65536) return p;
- } catch (e) { /* no prior port recorded */ }
- }
- return randomPort();
-}
-let PORT = preferredPort();
-const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
-const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
-const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
-const CONTENT_DIR = path.join(SESSION_DIR, 'content');
-const STATE_DIR = path.join(SESSION_DIR, 'state');
-const SUPERPOWERS_VERSION = readSuperpowersVersion();
-const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
-const TELEMETRY_DISABLE_ENV_VARS = [
- 'SUPERPOWERS_DISABLE_TELEMETRY',
- 'DISABLE_TELEMETRY',
- 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'
-];
-const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name]));
-let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
-
-// Per-session secret key. The companion is reachable by any local browser tab
-// and, when bound to a non-loopback host, by any host that can route to it.
-// The key authenticates the real client uniformly across loopback, tunnel, and
-// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist
-// cannot. It rides the served URL as ?key= and is mirrored into a cookie on
-// first load so same-origin subresources and the WebSocket carry it for free.
-// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the
-// same key and an already-open tab's cookie still validates.
-const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
-function generateToken() {
- return crypto.randomBytes(32).toString('hex');
-}
-
-function chmodOwnerOnly(file) {
- try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ }
-}
-
-function initialToken() {
- if (process.env.BRAINSTORM_TOKEN) {
- return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
- }
- if (TOKEN_FILE) {
- try {
- const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
- if (/^[0-9a-f]{32,}$/i.test(t)) {
- chmodOwnerOnly(TOKEN_FILE);
- return { value: t, source: 'file' };
- }
- } catch (e) { /* no prior token recorded */ }
- }
- return { value: generateToken(), source: 'generated' };
-}
-
-const tokenInfo = initialToken();
-let TOKEN = tokenInfo.value;
-let tokenSource = tokenInfo.source;
-let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
-
-const MIME_TYPES = {
- '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
- '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
-};
-
-// ========== Templates and Constants ==========
-
-function waitingPage() {
- return renderBranding(`
-
-Brainstorm Companion
-
-
-Brainstorm Companion
-Waiting for the agent to push a screen...
`);
-}
-
-const FORBIDDEN_PAGE = `
-
-Session key required
-
-
-Session key required
-This page needs the full URL your coding agent gave you, including the
-?key=… part. Copy the complete URL and open it again.
`;
-
-function bootstrapPage(key) {
- const jsonKey = JSON.stringify(String(key));
- return `
-
-Opening Brainstorm Companion
-
-
-
-`;
-}
-
-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 = '';
-
-// ========== Helper Functions ==========
-
-function readSuperpowersVersion() {
- const root = path.join(__dirname, '../../..');
- const manifests = [
- path.join(root, 'package.json'),
- path.join(root, '.codex-plugin/plugin.json')
- ];
-
- for (const manifest of manifests) {
- try {
- const data = JSON.parse(fs.readFileSync(manifest, 'utf-8'));
- if (data.version) return String(data.version);
- } catch (e) {
- // Packaged Codex plugins omit package.json; try the next manifest.
- }
- }
-
- return 'unknown';
-}
-
-function isTruthyEnv(value) {
- if (!value) return false;
- const normalized = String(value).trim().toLowerCase();
- if (!normalized) return false;
- return !['0', 'false', 'no', 'off'].includes(normalized);
-}
-
-function escapeHtmlText(value) {
- return String(value)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
-function brandMarkup() {
- const version = escapeHtmlText(SUPERPOWERS_VERSION);
- const text = SUPERPOWERS_TELEMETRY_DISABLED
- ? 'Prime Radiant Superpowers v' + version
- : 'Superpowers v' + version;
- const logo = SUPERPOWERS_TELEMETRY_DISABLED
- ? ''
- : '
';
-
- return '';
-}
-
-function renderBranding(html) {
- return html.split('').join(brandMarkup());
-}
-
-function isFullDocument(html) {
- const trimmed = html.trimStart().toLowerCase();
- return trimmed.startsWith('', content);
-}
-
-function getNewestScreen() {
- const files = fs.readdirSync(CONTENT_DIR)
- .filter(f => !f.startsWith('.') && f.endsWith('.html'))
- .map(f => {
- const fp = path.join(CONTENT_DIR, f);
- if (!isRegularFileInsideContentDir(fp)) return null;
- return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
- })
- .filter(Boolean)
- .sort((a, b) => b.mtime - a.mtime);
- return files.length > 0 ? files[0].path : null;
-}
-
-function urlHostForHttp(host) {
- const h = String(host);
- if (h.startsWith('[') && h.endsWith(']')) return h;
- return h.includes(':') ? '[' + h + ']' : h;
-}
-
-function companionUrl() {
- return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN;
-}
-
-function browserLauncherForPlatform(url, {
- platform = process.platform,
- osRelease = require('os').release(),
- env = process.env
-} = {}) {
- const isWSL = platform === 'linux' && /microsoft/i.test(osRelease);
- if (platform === 'darwin') return { bin: 'open', args: [url] };
- if (platform === 'win32' || isWSL) {
- return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] };
- }
- if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] };
- return null;
-}
-
-function isRegularFileInsideContentDir(filePath) {
- let stat, realContentDir, realFilePath;
- try {
- stat = fs.lstatSync(filePath);
- if (stat.isSymbolicLink()) return false;
- if (!stat.isFile()) return false;
- if (stat.nlink !== 1) return false;
- realContentDir = fs.realpathSync(CONTENT_DIR);
- realFilePath = fs.realpathSync(filePath);
- } catch (e) {
- return false;
- }
- return realFilePath.startsWith(realContentDir + path.sep);
-}
-
-// ========== Authentication ==========
-
-function timingSafeEqualStr(a, b) {
- const ab = Buffer.from(String(a));
- const bb = Buffer.from(String(b));
- if (ab.length !== bb.length) return false;
- return crypto.timingSafeEqual(ab, bb);
-}
-
-function parseCookies(header) {
- const out = {};
- if (!header) return out;
- for (const part of header.split(';')) {
- const eq = part.indexOf('=');
- if (eq < 0) continue;
- out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
- }
- return out;
-}
-
-// A request is authorized if it carries the session key as ?key= or as the
-// session cookie. Both are compared in constant time.
-function isAuthorized(req) {
- const q = req.url.indexOf('?');
- if (q >= 0) {
- const params = new URLSearchParams(req.url.slice(q + 1));
- if (params.has('key')) {
- const key = params.get('key');
- return Boolean(key && timingSafeEqualStr(key, TOKEN));
- }
- }
- const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
- if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
- return false;
-}
-
-function pathnameOf(url) {
- const q = url.indexOf('?');
- return q >= 0 ? url.slice(0, q) : url;
-}
-
-function queryKey(url) {
- const q = url.indexOf('?');
- if (q < 0) return null;
- return new URLSearchParams(url.slice(q + 1)).get('key');
-}
-
-function securityHeaders(headers = {}) {
- return {
- 'Referrer-Policy': 'no-referrer',
- 'Cache-Control': 'no-store',
- 'X-Frame-Options': 'DENY',
- 'Content-Security-Policy': "frame-ancestors 'none'",
- 'Cross-Origin-Resource-Policy': 'same-origin',
- ...headers
- };
-}
-
-function isAllowedWebSocketOrigin(req) {
- const origin = req.headers.origin;
- if (!origin) return true;
- const host = req.headers.host;
- if (!host) return false;
- return origin === 'http://' + host;
-}
-
-// ========== HTTP Request Handler ==========
-
-function handleRequest(req, res) {
- if (!isAuthorized(req)) {
- res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
- res.end(FORBIDDEN_PAGE);
- return;
- }
- touchActivity(); // only authorized requests count as activity
-
- // Mirror the key into a cookie so same-origin subresources (/files/*) can
- // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the
- // WebSocket Origin check below is what blocks cross-origin localhost injection.
- res.setHeader('Set-Cookie',
- COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
-
- const pathname = pathnameOf(req.url);
- const keyFromQuery = queryKey(req.url);
- if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
- res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
- res.end(bootstrapPage(keyFromQuery));
- } else if (req.method === 'GET' && pathname === '/') {
- const screenFile = getNewestScreen();
- let html = screenFile
- ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
- : waitingPage();
-
- if (html.includes('