`. 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
new file mode 100644
index 0000000..f540bb8
--- /dev/null
+++ b/.claude/skills/brainstorming/scripts/frame-template.html
@@ -0,0 +1,213 @@
+
+
+
+
+ Superpowers Brainstorming
+
+
+
+
+
+
+
+
+
diff --git a/.claude/skills/brainstorming/scripts/helper.js b/.claude/skills/brainstorming/scripts/helper.js
new file mode 100644
index 0000000..e11d264
--- /dev/null
+++ b/.claude/skills/brainstorming/scripts/helper.js
@@ -0,0 +1,167 @@
+(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
new file mode 100644
index 0000000..a828b35
--- /dev/null
+++ b/.claude/skills/brainstorming/scripts/server.cjs
@@ -0,0 +1,723 @@
+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('