#!/usr/bin/env node /** * Visual question server: present a decision to the user as a themed page * instead of a plain-text prompt, then block until they answer. * * The script IS the wait: run it via the shell, it serves the page, prints * the URL (and tries to open the default browser), and does not exit until * the user chooses. The answer lands on stdout as one line: * * ANSWER: {"optionId":"...","steer":"..."} * * Exit codes: 0 answered · 2 timed out, closed without answering, or no * browser is available (IMPECCABLE_QUESTION_DISABLED, or a detected * CI/headless/remote environment; IMPECCABLE_QUESTION_FORCE=1 overrides * detection, --no-open skips it since the caller opens the URL itself). * * Payload (JSON file via --payload, or stdin): * { * "title": "Choose the visual world", * "question": "The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.", * "options": [ * { * "id": "assigned", // returned verbatim * "label": "Fillmore Handbill", * "kicker": "THE ROLL", // optional badge; the assigned option leads * "lineage": "1966-71 Fillmore ...", // optional * "thesis": "one line: the idea this direction owns", // optional * "palette": ["#1a2f5e", "oklch(84% .19 80)", ...], // optional, rendered as chips * "materials": ["letterpress", "newsprint"], // optional, rendered as tags * "viewport": "one line: the first-viewport composition", // optional * "case": "one line: the fusion verdict, honest", // optional * "verdict": "competitive", // optional routing tier: "wins" | * // "competitive" | "declined". Declined cards * // render demoted after the full cards: * // narrow, quiet, catalog art as a labeled * // thumb, "Adopt anyway" instead of "Build * // this". Still choosable; never deleted. * "kept": "one line: what the direction kept from this declined world", * "raised": [ { "from": "challenger-x", "raise": "one line" } ], * // assigned card only: donations taken from * // declined challengers, rendered as named * // raise lines under the identity row * "risk": "one line: the honest risk", // optional * "body": "fallback prose when the structured fields are absent", * "comp": ".impeccable/mocks/decision/assigned.webp", // optional; the card's * // full-fidelity direction comp (the legacy * // key "sketch" is accepted as an alias). May * // not exist yet: the page shimmer-waits and * // polls the slot until the file lands, so * // serve first and generate after * "hero": "https://... or /abs/path.webp", // optional inspiration image; * // rides picture-in-picture when a comp exists * "board": "https://... or /abs/path.webp" // optional secondary image * }, ... * ], * "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"}) * // or { "registers": ["safer", "bolder"] } to add * // the register steers beside it: the answer then * // carries "register" and the agent re-runs * // concept-seed with --register * "canon": true, // adds the "Play it straight" standing exit; * // direction rounds only (returns {"optionId":"canon"}) * "canonCard": { ... }, // optional: the standing exit as a full card with the * // same anatomy (label, thesis, palette, comp, ...); * // rendered last and visually subordinate. Without it, * // canon stays a quiet footer action. * "steer": true, // adds a free-text steer field returned with any answer * "followup": true // this round's pick is not terminal: the server * // stays open awaiting --update with the next * // round (detached mode only), the page shows a * // loading hand instead of goodbye, and the * // answer carries followup:true so --wait knows * // to keep the table. Use it when a decision has * // a known second half, e.g. direction first, * // then the execution contract. * } * * Options render as large cards: the comp leads when present, with the * inspiration image picture-in-picture; a hero alone renders full-bleed; a * text-only direction gets its identity from the palette chips and tags. * Local image paths are served by this server; nothing is uploaded anywhere. * * Modes: * (default) block until answered; ANSWER on stdout; exit 0. * --schema print the canonical payload example and exit. * --start for harnesses that cannot leave a shell blocked: daemonize the * server, print QUESTION URL + QUESTION KEY, exit immediately. * Never auto-opens a browser: the agent routes the URL to the * best surface it has (in-app browser first, then the system * opener); pass --open to force the system browser instead. * --wait --key K [--poll 60] poll for the answer: exit 0 + ANSWER line, * exit 3 WAITING (run --wait again), exit 2 server gone, * exit 4 PAGE CLOSED (the tab went away without an answer; * re-present, reopen the URL, or fall back). * --stop --key K kill a daemonized question. * --update --key K --payload F deliver the next hand after a re-roll: the * live page swaps to loading cards when the user re-rolls, and * reloads into this new payload the moment it lands. Always the * same key the round started with; a second --start serves a new * URL and strands the open tab on a hand that never arrives. * * --timeout bounds the wait for a page to arrive, never the user's decision: * once the page heartbeats, the server lives while the page does, and exits * only after --idle-grace seconds (default 600) pass with no beat, wide * enough to survive a closed laptop lid mid-decision. * * node serve-question.mjs --payload question.json [--timeout 900] [--idle-grace 600] [--no-open] [--port 0] */ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { openSystemBrowser } from './lib/open-system-browser.mjs'; function arg(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); if (i === -1) return fallback; const v = process.argv[i + 1]; return v && !v.startsWith('--') ? v : fallback; } const hasFlag = (name) => process.argv.includes(`--${name}`); if (process.env.IMPECCABLE_QUESTION_DISABLED) { console.log('serve-question: disabled in this session (no browser); use the structured question tool instead.'); process.exit(2); } // Headless self-detection, applied only where a browser is actually wanted. // --no-open means the caller opens the URL itself, and --wait / --stop / // --schema / --update never open anything: --wait polls a daemon whose // browser question was already settled at --start, --stop kills one, // --schema prints text, and --update hands the next round to a page that is // already open. A spurious exit 2 from those breaks the documented loop, // which polls --wait while it exits 3, reads --schema before building a // payload, and delivers re-rolled hands with --update. const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema') && !hasFlag('update'); if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) { const headless = process.env.CI || (process.env.SSH_CONNECTION && !process.env.DISPLAY) || (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY); if (headless) { console.log('serve-question: no browser detected in this environment (CI/headless/remote); use the structured question tool instead. Set IMPECCABLE_QUESTION_FORCE=1 to serve anyway.'); process.exit(2); } } // Both answer channels (blocking stdout and --wait collection) print through // this: the ANSWER line, then a directive to open the chosen card's imagery // when it has any. The card viewing happens at the moment of choice, in the // working turn, because a build that never reopens the chosen world's board // and hero calibrates on nothing. function printAnswer(raw) { console.log(`ANSWER: ${raw}`); try { const a = JSON.parse(raw); if (a.hero || a.board) { console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach."); } if (a.comp) { console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.'); } if (a.optionId === 'canon') { console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.'); } if (a.optionId === 'reroll' && a.register) { console.log(`REGISTER: the user steered the next hand to the ${a.register} register. Re-run concept-seed with the same key, the next --reroll round, and --register ${a.register}, then follow what it prints; the register is the user's steering, never yours to pre-select.`); } if (a.followup && a.optionId !== 'reroll') { console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key --payload , then collect it with --wait; never leave the page waiting on a round you have not sent.'); } if (a.buildPath === 'comp' || a.buildPath === 'code') { // The page never writes the flip itself, but "never write it" overstated // that into a rule the agent then applied to new-work's one-time offer, // which exists for exactly this case: a flip on a project that had no // recorded default is the only moment the preference is ever asked for. const origin = a.buildPathFlipped ? 'flipped on the page, so it binds this session only, and the page never writes it back; the sole exception is new-work’s one-time offer, on a project that had no recorded default at all, which asks after the round closes and writes the answer to .impeccable/config.json' : 'the round’s recorded default'; console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp' ? 'Comp-led: the chosen card’s comp is law; generate it before building when it does not exist yet, and the finish review audits the build against it.' : 'Code-led: no comp is owed; a comp that already rendered rides at the finish review as the critique reference, and the ambition lives in the direction contract.'}`); } } catch { /* raw answer */ } } const payloadPath = arg('payload'); // --timeout bounds only the wait for a page to open; 0 is the explicit // wait-forever. A negative or unparseable value takes the default, so a // typo cannot disarm the no-page exit and leak the daemon. const timeoutArg = Number(arg('timeout', '900')); const timeoutSec = Number.isFinite(timeoutArg) && timeoutArg >= 0 ? timeoutArg : 900; // How long the server (and the page's own delivery deadline) outlive the // last heartbeat; a zero, negative, or unparseable value takes the default. const idleGraceArg = Number(arg('idle-grace', '600')); const idleGraceMs = (Number.isFinite(idleGraceArg) && idleGraceArg > 0 ? idleGraceArg : 600) * 1000; // How long a delivered next hand may sit unclaimed before it means no page // is coming back: --wait reads it to keep a stalled page from counting as // closed mid-delivery, and the daemon reads it to survive until the page's // watch claims a hand delivered moments before the idle deadline. const NEXT_CLAIM_GRACE_MS = 10000; const portArg = Number(arg('port', '0')); const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions'); const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`); const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`); // A code-to-comp flip mid-round: the page records it here and --wait // surfaces it as its own event, because the agent must start generating // comps while the round is still open. Comp-to-code needs no event; it is // free and rides the final ANSWER. const flipFile = (key) => path.join(QUESTION_DIR, `${key}.flip.json`); if (hasFlag('schema')) { console.log(JSON.stringify({ title: 'Choose the visual world', question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.', options: [ { id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], comp: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' }, { id: 'model-pick', label: 'The Broadside Ballad', kicker: 'IMPECCABLE’S PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as today’s ballad.', risk: 'Also the direction most runs in this category land on.', comp: '.impeccable/mocks/decision/model-pick.webp' }, { id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', comp: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' }, { id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' }, ], reroll: { registers: ['safer', 'bolder'] }, buildPath: { value: 'comp', toggle: true }, canon: true, canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' }, steer: true, }, null, 2)); console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "IMPECCABLE’S PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challenger’s system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the card’s full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.'); process.exit(0); } if (hasFlag('wait')) { const key = arg('key'); if (!key) { console.error('serve-question: --wait needs --key'); process.exit(1); } const pollSec = Number(arg('poll', '60')); const deadline = Date.now() + pollSec * 1000; const answered = () => fs.existsSync(answerFile(key)); // Liveness must survive sandboxes: a sandboxed --wait cannot signal the // daemon (kill throws EPERM even for a living process), so a fresh page // heartbeat in the state file is the primary proof of life, the kill probe // is secondary, and EPERM specifically means "exists, but the sandbox // blocks signals", never "dead". Treating EPERM as death told one session // the user had walked away while they were still reading the board. const alive = () => { try { const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); if (state.lastBeat && Date.now() - state.lastBeat < 12000) return true; try { process.kill(state.pid, 0); return true; } catch (err) { return err.code === 'EPERM'; } } catch { return false; } }; let sawClose = false; while (Date.now() < deadline) { if (answered()) break; // A build-path flip is its own event, not an answer: the round stays // open, and the agent's job right now is comps, not code. if (fs.existsSync(flipFile(key))) { try { fs.rmSync(flipFile(key)); } catch { /* consumed elsewhere */ } console.log('BUILD PATH FLIPPED: comp (for this session only; never write it to settings). The table is still open and the page shows shimmer where the images will land: generate each open card’s comp into its declared path now, lead first, then collect the answer with --wait again. A card whose comp already exists needs nothing.'); process.exit(0); } if (!alive()) { console.log('serve-question: the question server is gone with no answer. This is a server failure, not a user decision: restart it with --start and the same payload, reopen the URL for the user, and wait again. Never proceed without their choice while their browser session is open.'); process.exit(2); } try { const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); // A silent page is not a closed one while a freshly delivered next // hand sits unclaimed: a stalled page stops beating by design and its // watch reloads, beating again, within seconds of the file landing. // The suppression is age-bound because a closed tab never claims the // hand: a file still there after the grace means no page is coming. const midDelivery = (() => { try { if (Date.now() - fs.statSync(path.join(QUESTION_DIR, `${key}.next.json`)).mtimeMs < NEXT_CLAIM_GRACE_MS) return true; } catch { /* nothing delivered */ } // The claim deletes that file before the reloaded page can beat: the // claim stamp the server persisted covers the same bounded gap. return Boolean(state.claimedAt) && Date.now() - state.claimedAt < NEXT_CLAIM_GRACE_MS; })(); if (!midDelivery && state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; } } catch { /* state mid-write */ } await new Promise((r) => setTimeout(r, 1000)); } if (sawClose && !answered()) { console.log('PAGE CLOSED: the question page went away without an answer; re-present, reopen the URL, or fall back to the structured question tool'); process.exit(4); } if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); } const collected = fs.readFileSync(answerFile(key), 'utf8').trim(); printAnswer(collected); // A re-roll or a followup-round pick keeps the table open: the server stays // alive awaiting --update, so only the answer file is consumed. Terminal // choices clean up fully. let keepsTableOpen = false; try { const parsedAnswer = JSON.parse(collected); keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true; } catch { /* treat as terminal */ } try { fs.rmSync(answerFile(key)); } catch { /* already gone */ } if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } } process.exit(0); } if (hasFlag('stop')) { const key = arg('key'); if (!key) { console.error('serve-question: --stop needs --key'); process.exit(1); } try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid); } catch { /* dead already */ } try { fs.rmSync(answerFile(key)); } catch {} try { fs.rmSync(stateFile(key)); } catch {} console.log('stopped'); process.exit(0); } if (hasFlag('update')) { const key = arg('key'); if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); } // A hand the server cannot load must fail here, at the sender: delivered // anyway, the page would see ready:true for a round that never renders. const nextRound = JSON.parse(fs.readFileSync(payloadPath, 'utf8')); if (!nextRound || !Array.isArray(nextRound.options) || nextRound.options.length === 0) { console.error('serve-question: --update payload needs an options array; nothing was delivered. Fix the payload and rerun --update on the same key.'); process.exit(1); } // Liveness mirrors --wait: a fresh page heartbeat is the primary proof, the // kill probe is secondary, and EPERM means a sandbox blocked the signal, // never a dead server. This is the documented re-roll delivery step, so a // false "no live server" here strands the page mid-shuffle. const live = (() => { try { const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); if (state.lastBeat && Date.now() - state.lastBeat < 12000) return true; try { process.kill(state.pid, 0); return true; } catch (err) { return err.code === 'EPERM'; } } catch { return false; } })(); if (!live) { console.error('serve-question: no live question server for that key; the page it served is gone too. Re-present the round with --start and a fresh key, or fall back to the structured question tool.'); process.exit(2); } const deliveredFile = path.join(QUESTION_DIR, `${key}.next.json`); fs.copyFileSync(payloadPath, deliveredFile); // The file's mtime is the delivery clock --wait's grace reads: stamp it // here, because a copy that preserves the source payload's older mtime // would start the grace already spent. const deliveredAt = new Date(); fs.utimesSync(deliveredFile, deliveredAt, deliveredAt); console.log('next round delivered; the page reloads itself'); process.exit(0); } if (hasFlag('start')) { if (!payloadPath) { console.error('serve-question: --start needs --payload '); process.exit(1); } JSON.parse(fs.readFileSync(payloadPath, 'utf8')); fs.mkdirSync(QUESTION_DIR, { recursive: true }); const key = arg('key') || Math.random().toString(16).slice(2, 10); // In start mode the agent is alive and owns browser routing; the server // only opens the system browser itself when --open forces it. // The daemon's output lands in a per-key log so a startup failure can say // what actually went wrong instead of only that it did. const logFile = path.join(QUESTION_DIR, `${key}.log`); const logFd = fs.openSync(logFile, 'a'); const child = spawn(process.execPath, [ fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key, '--timeout', String(timeoutSec), ...(arg('idle-grace') ? ['--idle-grace', arg('idle-grace')] : []), ...(hasFlag('open') ? [] : ['--no-open']), ], { detached: true, stdio: ['ignore', logFd, logFd] }); child.unref(); fs.closeSync(logFd); const deadline = Date.now() + 8000; while (Date.now() < deadline && !fs.existsSync(stateFile(key))) await new Promise((r) => setTimeout(r, 100)); if (!fs.existsSync(stateFile(key))) { let tail = ''; try { tail = fs.readFileSync(logFile, 'utf8').trim().split('\n').slice(-4).join('\n '); } catch { /* log never written */ } console.error(`serve-question: server failed to start${tail ? `\n ${tail}` : ''}`); console.error(`serve-question: log at ${path.relative(process.cwd(), logFile) || logFile}. A sandboxed exec that cannot listen on localhost causes exactly this; rerun this command once through the harness's network-enabled or unsandboxed command tool before falling back.`); process.exit(1); } const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); console.log(`QUESTION URL: ${state.url}`); console.log(`QUESTION KEY: ${key}`); console.log('Open the URL for the user now: in-app browser when the harness has one, otherwise the system opener (macOS `open`, Linux `xdg-open`), otherwise show the URL.'); console.log(`Then collect the answer with: node ${fileURLToPath(import.meta.url)} --wait --key ${key}`); process.exit(0); } let raw; if (payloadPath) raw = fs.readFileSync(payloadPath, 'utf8'); else raw = fs.readFileSync(0, 'utf8'); // Round state is mutable: a re-roll keeps this server alive and --update // swaps in the next hand, so payload, options, and the local-image table // rebuild per round. let payload; let options; let localImages = []; // Build path (comp-led vs code-led): the payload carries the recorded // default; the page's toggle updates the live value per session. The server // owns both so the final ANSWER states the path and whether it was flipped // even when the round never rendered a toggle. let buildPathDefault = null; let liveBuildPath = null; // True between a collected re-roll or followup answer and the --update that // replaces the round: the window where GET / must serve the wait, not the // answered cards. The timestamp anchors the delivery deadline server-side, // so a native refresh re-enters the wait with the time already spent, never // with a fresh allowance. let awaitingNext = false; let awaitingNextSince = 0; function loadRound(json) { const parsed = JSON.parse(json); if (!parsed || !Array.isArray(parsed.options) || parsed.options.length === 0) { throw new Error('payload needs an options array'); } localImages = []; const imageSrc = (value) => { if (!value) return null; if (/^https?:\/\//.test(value)) return value; const abs = path.resolve(value); if (!fs.existsSync(abs)) return null; localImages.push(abs); return `/img/${localImages.length - 1}`; }; // Comps stream in after the page is served, so their slots register // whether or not the file exists yet; /img answers 404 until it lands and // the page polls the slot. Remote comp URLs pass through untouched. const compSrc = (value) => { if (!value) return null; if (/^https?:\/\//.test(value)) return value; localImages.push(path.resolve(value)); return `/img/${localImages.length - 1}`; }; payload = parsed; const decorate = (option) => ({ ...option, heroSrc: imageSrc(option.hero), boardSrc: imageSrc(option.board), compSrc: compSrc(option.comp ?? option.sketch), }); options = parsed.options.map(decorate); // The verdict routes rendering: full cards first, then the canon, then the // declined cards dead last in their own payload order. The reorder happens // here so a payload that interleaves them still renders the weighing's // shape, and the deck reads as a gradient of standing: contenders, the // familiar door, then the demoted row. const declined = options.filter((o) => o.verdict === 'declined'); options = options.filter((o) => o.verdict !== 'declined'); // The standing exit as a full card: same anatomy, reserved id, rendered // subordinate by the page. Without it, canon stays the quiet footer action. if (parsed.canonCard && typeof parsed.canonCard === 'object') { options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }]; } options = [...options, ...declined]; buildPathDefault = (parsed.buildPath && (parsed.buildPath.value === 'comp' || parsed.buildPath.value === 'code')) ? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true } : null; liveBuildPath = buildPathDefault?.value ?? null; // Last: a round that failed to load anywhere above must leave the waiting // window open, never resurrect the answered cards. awaitingNext = false; } try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); } const detachedKey = hasFlag('detached-serve') ? arg('key') : null; const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.next.json`) : null; const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function page(waiting = false) { // The delivery deadline survives refreshes: a waiting page gets whatever // remains of the original allowance, so reloading cannot renew it. Spent // means the page renders already stalled and never starts a heartbeat. const waitBudgetMs = waiting ? Math.max(0, awaitingNextSince + idleGraceMs - Date.now()) : idleGraceMs; const flipChip = (label) => ``; const expandChip = ``; // Structured anatomy: chips and one-line facts render when the payload // carries them; a plain body falls back to the prose block. Palette chips // and material tags give a text-only direction an immediate identity that // no generation luck can distort. const fact = (label, value, cls = '') => value ? `

${label}${esc(value)}

` : ''; const demoted = (option) => option.verdict === 'declined'; // The build path (comp-led vs code-led) is a workflow preference, not a // design decision: the payload carries the recorded default and whether // the page offers the toggle. On a code-led round a declared comp path is // a flip reserve, not a face: wireframes render, and the slot only starts // shimmering when the user flips to comp. const buildPath = buildPathDefault; const codeLed = buildPath?.value === 'code'; // Salience parity: a card's imagery weight is capped by the assigned card's. // When the lead card has no media at all (no image generation this round, // and no catalog art of its own), full-bleed catalog art beside a text-only // assigned card would let rendering luck outvote the weighing: users click // the colorful thing. Declined cards are thumb-only regardless; the verdict // demoted them, and a full-bleed hero would promote them right back. const identityRound = !(options[0] && (options[0].compSrc || options[0].heroSrc || options[0].boardSrc)); // A declined card never renders a full media face, comp included: even a // declared comp would buy back the salience the verdict took away. const faceComp = (option) => (demoted(option) || codeLed) ? null : option.compSrc; const thumbOnly = (option) => !faceComp(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound); const hasMedia = (option) => Boolean(faceComp(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option))); // The back exists to keep long facts off a card whose front is an image; // a card with no art has no flip chip to reach it, so it gets no back and // the full read lives on the front instead. const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc)); const anatomy = (option) => { const rows = []; if (option.thesis) rows.push(`

${esc(option.thesis)}

`); const idBits = []; if (Array.isArray(option.palette) && option.palette.length) { idBits.push(`${option.palette.slice(0, 6).map((c) => ``).join('')}`); } if (Array.isArray(option.materials) && option.materials.length) { idBits.push(option.materials.slice(0, 4).map((m) => `${esc(m)}`).join('')); } if (idBits.length) rows.push(`
${idBits.join('')}
`); // Donations from declined challengers render as named raise lines: the // assigned card arrives already raised by the hand it beat, and the raise // is readable, because a raise nobody can read did not happen. One raise // renders inline; several become a compact cycler (click advances), so a // generous hand cannot blow the card out of proportion. if (Array.isArray(option.raised) && option.raised.length) { const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? ''); const raiseLines = option.raised.slice(0, 6).map((r) => `

From ${esc(nameOf(r.from))}${esc(r.raise || r.kept || '')}

`); const raisesHead = (count) => `
Improved by Impeccable's worlds${count > 1 ? `1/${count}` : ''}
`; if (raiseLines.length > 1) { rows.push(`
${raisesHead(raiseLines.length)} ${raiseLines.join('')}
`); } else { rows.push(`
${raisesHead(1)}${raiseLines[0]}
`); } } // Demoted art stays reachable as a labeled thumb: the catalog world // explains where the direction comes from without buying it back the // salience the verdict took away. if (thumbOnly(option)) { rows.push(`
inspired by
`); } // The front carries only what the choice needs: thesis, identity, and the // honest risk clamped to two lines. First viewport and the case read on // the card's back; once the comp lands, the first viewport is a picture. // With no art there is no back, so the full read fills the room the // image would have taken. if (hasMedia(option)) { rows.push(fact('Risk', option.risk, 'clamp')); } else { rows.push(fact('First viewport', option.viewport)); rows.push(fact('The case', option.case)); rows.push(fact('Kept', option.kept)); rows.push(fact('Risk', option.risk)); } if (!option.thesis && option.body) rows.push(`

${esc(option.body)}

`); else if (option.body && option.thesis && !hasBack(option)) rows.push(`

${esc(option.body)}

`); return rows.join('\n '); }; const backFacts = (option) => [ fact('First viewport', option.viewport), fact('The case', option.case), fact('Kept', option.kept), fact('Risk', option.risk), option.body && option.thesis ? `

${esc(option.body)}

` : '', ].filter(Boolean).join('\n '); const media = (option) => { const inspirationSrc = option.heroSrc || option.boardSrc; const inspiration = inspirationSrc ? `
inspiration
` : ''; const details = hasBack(option) ? flipChip('Details') : ''; // Thumb-only art renders inside the body via anatomy(), never as a face, // and a declined card's comp slot is ignored outright. if (thumbOnly(option)) return ''; if (faceComp(option)) { const textOnlyFacts = backFacts(option); return `
rendering…
${inspiration}
${expandChip}${details}
`; } if (option.heroSrc || option.boardSrc) { // Without a comp the catalog art is the card's face; it stays a // labeled reference so it never reads as the promise of the build. return `

inspiration

${expandChip}${details}
`; } return ''; }; // Wireframe media: a code-led card's layout schematic, authored as grid // regions in the payload and drawn by the page; boxes and labels, no art. // It fills the media slot only when the card has no imagery, and it never // counts toward salience or earns a card back: the full read stays on the // front, exactly like a text-only card. const wire = (option) => { const frame = option.wireframe; if (!frame || !Array.isArray(frame.regions) || !frame.regions.length || media(option) || demoted(option)) return ''; const cols = Number(frame.cols) > 0 ? Number(frame.cols) : 12; const rows = Number(frame.rows) > 0 ? Number(frame.rows) : 10; const pct = (n, total) => `${Math.max(0, Math.min(100, (n / total) * 100)).toFixed(2)}%`; const cells = frame.regions.slice(0, 12).map((region) => { const x = Number(region.x) || 0; const y = Number(region.y) || 0; const w = Math.max(Number(region.w) || 1, 0.5); const h = Math.max(Number(region.h) || 1, 0.5); return `
${esc(region.label || '')}
`; }).join(''); return ``; }; const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this'; const cards = options.map((option, index) => `
${option.kicker ? `${esc(option.kicker)}` : demoted(option) ? 'Declined' : option.isCanon ? 'The standing door' : ''} ${media(option) || wire(option)}
${option.lineage ? `

${esc(option.lineage)}

` : ''}

${esc(option.label)}

${anatomy(option)}
${hasBack(option) ? `
${option.boardSrc ? `
${expandChip}${flipChip('Front')}
` : `

The full read · ${esc(option.label)}

${flipChip('Front')}
`}
${option.boardSrc ? `

The full read · ${esc(option.label)}

` : ''} ${backFacts(option)}
` : ''}
`).join('\n'); return ` ${esc(payload.title || 'impeccable · decision')} ${buildPath?.toggle ? `` : ''}
Impeccable

${esc(payload.title || 'Choose a direction')}

${buildPath?.toggle ? `

` : ''}
${payload.question ? `

${esc(payload.question)}

` : ''}
${cards}
${payload.steer ? '' : ''} ${(() => { if (!payload.reroll) return ''; const die = ''; const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : []; // The registers are the user's steering wheel on the familiar-to-bold // axis; the plain re-roll sits between them so the spatial order matches // the axis it names. const safer = registers.includes('safer') ? '' : ''; const bolder = registers.includes('bolder') ? '' : ''; return `${safer}${bolder}`; })()} ${payload.canon && !payload.canonCard ? '' : ''}
`; } const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/') { const pending = nextFile(); if (pending && fs.existsSync(pending)) { // A next file the round cannot load has to leave the disk either way: // kept, /next-status stays ready:true and the waiting page reloads // into the same failure without bound. try { loadRound(fs.readFileSync(pending, 'utf8')); } catch { /* keep current round */ } try { fs.rmSync(pending); } catch { /* already gone */ } // The claim consumes the file the idle-exit hold reads, and the // reloading page cannot beat until it has parsed: stamp the claim so // the same bounded grace covers the gap between them. Persisted too, // because --wait watches the same gap from outside this process and // would otherwise read the stale beat as a closed page. server.lastClaimAt = Date.now(); if (detachedKey) { try { const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); state.claimedAt = server.lastClaimAt; fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); } catch { /* state file recreated on next beat */ } } } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(page(awaitingNext)); return; } if (req.method === 'POST' && req.url === '/heartbeat') { res.writeHead(204); res.end(); server.lastBeatSeen = Date.now(); if (detachedKey) { const now = Date.now(); if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) { server.lastBeatWrite = now; try { const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); state.lastBeat = now; fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); } catch { /* state file recreated on next beat */ } } } return; } if (req.method === 'GET' && req.url === '/next-status') { const pending = nextFile(); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) })); return; } const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/); if (imageMatch) { const abs = localImages[Number(imageMatch[1])]; if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; } const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : abs.endsWith('.svg') ? 'image/svg+xml' : abs.endsWith('.gif') ? 'image/gif' : 'image/jpeg'; res.writeHead(200, { 'content-type': type }); fs.createReadStream(abs).pipe(res); return; } if (req.method === 'POST' && req.url === '/build-path') { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } if (value !== 'comp' && value !== 'code') return; const wasComp = liveBuildPath === 'comp'; liveBuildPath = value; // Only a flip TO comp needs the agent mid-round: comps must start // rendering into the declared slots. The reverse is free. if (detachedKey && value === 'comp' && !wasComp) { fs.mkdirSync(QUESTION_DIR, { recursive: true }); fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); } }); return; } if (req.method === 'POST' && req.url === '/answer') { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); let parsed = {}; try { parsed = JSON.parse(body); } catch { /* empty steer */ } const chosen = options.find((o) => o.id === parsed.optionId); const isReroll = parsed.optionId === 'reroll'; // A followup round's pick is not terminal: the table stays open for the // next round (--update), exactly like a re-roll. Detached mode only; // the blocking mode has no update channel, so its picks stay terminal. const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll; const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '', ...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}), ...(followupOpen ? { followup: true } : {}), ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}), ...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}), ...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}), }); // The delivery deadline is single-issue: a duplicate answer racing the // page's disable must not restamp the allowance already inherited. const wasAwaiting = awaitingNext; awaitingNext = (isReroll || followupOpen) && Boolean(detachedKey); if (awaitingNext && !wasAwaiting) awaitingNextSince = Date.now(); if (detachedKey) { fs.mkdirSync(QUESTION_DIR, { recursive: true }); fs.writeFileSync(answerFile(detachedKey), answer + '\n'); } else { printAnswer(answer); } // A re-roll or followup pick in detached mode keeps the table open: the // client shows a loading hand and reloads when --update delivers the // next round. if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150); }); return; } res.writeHead(404); res.end(); }); server.listen(portArg, '127.0.0.1', () => { const { port } = server.address(); const url = `http://127.0.0.1:${port}/`; if (hasFlag('detached-serve')) { fs.mkdirSync(QUESTION_DIR, { recursive: true }); fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url })); } else { console.log(`QUESTION URL: ${url}`); console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); } if (!hasFlag('no-open')) { openSystemBrowser(url); } // The timeout bounds the wait for a page, never the user's decision: an // absolute guillotine counted from start used to kill the server under a // still-open tab (a slow re-rolled round easily outlived it), leaving the // page polling skeletons that could never resolve. Once the page beats, // the server's lifetime tracks the beats, and it exits only after the idle // grace passes with none, long enough to survive a closed laptop lid. // --timeout 0 waits for a page forever, but the idle grace still applies // once one has beat: a page that arrived and went silent is a closed tab, // and no timeout setting should let that daemon leak. const startedAt = Date.now(); const lifetime = setInterval(() => { if (!server.lastBeatSeen) { if (timeoutSec > 0 && Date.now() - startedAt > timeoutSec * 1000) { console.log('serve-question: timed out with no answer'); process.exit(2); } } else if (Date.now() - server.lastBeatSeen > idleGraceMs) { // A hand delivered moments before this deadline still gets its claim // window: the stalled page's watch reloads into it and beats again // within seconds, while a file unclaimed past the grace means no page // is coming back (the same verdict --wait reads from its age). The // claim itself holds the daemon too: GET / deletes the file before the // reloaded page can beat, so a tick in that gap must not exit under // the hand just claimed. const pending = nextFile(); let deliveredAt = 0; if (pending) { try { deliveredAt = fs.statSync(pending).mtimeMs; } catch { /* nothing delivered */ } } if (Date.now() - Math.max(deliveredAt, server.lastClaimAt || 0) > NEXT_CLAIM_GRACE_MS) { console.log('serve-question: the page stopped beating and never came back; exiting'); process.exit(2); } } }, 2000); lifetime.unref?.(); });