`. 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
@@ -50,5 +141,24 @@ Start by understanding the current project context, then ask questions one at a
- **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 in sections, validate each
+- **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:** When you anticipate that upcoming questions will involve visual content (mockups, layouts, diagrams), offer it once for consent:
+> "Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, diagrams, comparisons, and other visuals as we go. This feature is still new and can be token-intensive. Want to try it? (Requires opening a local URL)"
+
+**This offer MUST be its own message.** Do not combine it with clarifying questions, context summaries, or any other content. The message should contain ONLY the offer above and nothing else. Wait for the user's response before continuing. If they decline, proceed with text-only brainstorming.
+
+**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/codex/skills/brainstorming/scripts/frame-template.html b/codex/skills/brainstorming/scripts/frame-template.html
new file mode 100644
index 0000000..dcfe018
--- /dev/null
+++ b/codex/skills/brainstorming/scripts/frame-template.html
@@ -0,0 +1,214 @@
+
+
+
+
+ Superpowers Brainstorming
+
+
+
+
+
+
+
+
+ Click an option above, then return to the terminal
+
+
+
+
diff --git a/codex/skills/brainstorming/scripts/helper.js b/codex/skills/brainstorming/scripts/helper.js
new file mode 100644
index 0000000..111f97f
--- /dev/null
+++ b/codex/skills/brainstorming/scripts/helper.js
@@ -0,0 +1,88 @@
+(function() {
+ const WS_URL = 'ws://' + window.location.host;
+ let ws = null;
+ let eventQueue = [];
+
+ function connect() {
+ ws = new WebSocket(WS_URL);
+
+ ws.onopen = () => {
+ eventQueue.forEach(e => ws.send(JSON.stringify(e)));
+ eventQueue = [];
+ };
+
+ ws.onmessage = (msg) => {
+ const data = JSON.parse(msg.data);
+ if (data.type === 'reload') {
+ window.location.reload();
+ }
+ };
+
+ ws.onclose = () => {
+ setTimeout(connect, 1000);
+ };
+ }
+
+ 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
+ });
+
+ // Update indicator bar (defer so toggleSelect runs first)
+ setTimeout(() => {
+ const indicator = document.getElementById('indicator-text');
+ if (!indicator) return;
+ 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';
+ } 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';
+ } else {
+ indicator.innerHTML = '' + selected.length + ' selected — return to terminal to continue';
+ }
+ }, 0);
+ });
+
+ // 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/codex/skills/brainstorming/scripts/server.js b/codex/skills/brainstorming/scripts/server.js
new file mode 100644
index 0000000..dec2f7a
--- /dev/null
+++ b/codex/skills/brainstorming/scripts/server.js
@@ -0,0 +1,338 @@
+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';
+
+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;
+ payloadLen = Number(buffer.readBigUInt64BE(2));
+ offset = 10;
+ }
+
+ 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 = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
+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 SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
+const OWNER_PID = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
+
+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 ==========
+
+const WAITING_PAGE = `
+
+Brainstorm Companion
+
+
+Brainstorm Companion
+Waiting for Claude to push a screen...
`;
+
+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 isFullDocument(html) {
+ const trimmed = html.trimStart().toLowerCase();
+ return trimmed.startsWith('', content);
+}
+
+function getNewestScreen() {
+ const files = fs.readdirSync(SCREEN_DIR)
+ .filter(f => f.endsWith('.html'))
+ .map(f => {
+ const fp = path.join(SCREEN_DIR, f);
+ return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
+ })
+ .sort((a, b) => b.mtime - a.mtime);
+ return files.length > 0 ? files[0].path : null;
+}
+
+// ========== HTTP Request Handler ==========
+
+function handleRequest(req, res) {
+ touchActivity();
+ if (req.method === 'GET' && req.url === '/') {
+ const screenFile = getNewestScreen();
+ let html = screenFile
+ ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
+ : WAITING_PAGE;
+
+ if (html.includes('