Build path becomes a setting plus a page toggle; the followup contract round retires

- serve-question: payload buildPath { value, toggle } renders a footer
  segmented control (comp first / code first) with the trade stated in one
  line; the default comes from settings, a flip binds that session only.
  Code-led rounds treat declared comp paths as flip reserves: wireframes
  render, a flip to comp shimmers the slots and surfaces once through
  --wait as BUILD PATH FLIPPED so the agent starts generating mid-round;
  the flip back is free and a landed comp stays. The ANSWER carries
  buildPath and buildPathFlipped with a session-only directive.
- init Step 5 asks the preference once (only when image generation exists)
  and writes .impeccable/settings.json; context.mjs surfaces the recorded
  default every session; PRODUCT.md standing commitments stay honored as
  the fallback.
- new-work retires the two-card execution-contract round: no round asks a
  workflow preference. followup stays as the generic same-table mechanism.
- e2e: new toggle test (14/14 with the wireframe test).

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-12 16:56:39 -04:00
co-authored by Claude Fable 5
parent 6b7f62979b
commit 64dd60a78a
6 changed files with 215 additions and 13 deletions
+5 -3
View File
@@ -433,10 +433,12 @@ export function renderConceptSeed({
present the three dealt structures as full cards of equal salience, the
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
lock one in; the world is already settled, so this choice is composition.
Visualize every dealt card: with image generation available and no
standing code-led preference, declare a comp per card and generate after
Visualize every dealt card: with image generation available and a
comp-led default (.impeccable/settings.json buildPath; the page toggle
handles the exception), declare a comp per card and generate after
serving, lead first; otherwise author each card's wireframe field (see
serve-question --schema) and the page draws the schematic. Locking a card
serve-question --schema) and the page draws the schematic. Carry the
recorded default in the payload as buildPath with toggle: true. Locking a card
approves its comp: a surface round that put three visualized structures on
the table replaces the three-option comp round in visualize.md. Re-roll
yourself only when every dealt structure fails audience identification or
+15
View File
@@ -1142,6 +1142,7 @@ async function cli() {
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
appendBuildPathDirective(parts);
appendAutonomyCounterDirective(parts);
appendSubagentAuthorizationDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
@@ -1161,6 +1162,7 @@ async function cli() {
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
appendBuildPathDirective(parts);
appendAutonomyCounterDirective(parts);
appendSubagentAuthorizationDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
@@ -1269,6 +1271,19 @@ function automaticHookMode(ctx) {
}
// Build-path preference: a workflow setting (comp-led vs code-led), read
// here so every session starts knowing it without a file hunt. Absence
// stays silent; new-work's own default applies, and the decision page
// toggle can flip the value for a single session.
function appendBuildPathDirective(parts) {
try {
const settings = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.impeccable', 'settings.json'), 'utf8'));
if (settings.buildPath === 'comp' || settings.buildPath === 'code') {
parts.push(`BUILD_PATH_DEFAULT: ${settings.buildPath} (from .impeccable/settings.json). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back to settings.`);
}
} catch { /* no settings file */ }
}
// Image generation availability: harness-native tools always win, but when the
// environment carries an OpenAI key the API fallback works everywhere. The
// flag only reports capability, positively: absence stays silent, because a
+142 -5
View File
@@ -160,6 +160,14 @@ function printAnswer(raw) {
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 <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
}
if (a.buildPath === 'comp' || a.buildPath === 'code') {
const origin = a.buildPathFlipped
? 'flipped on the page, so it binds this session only; never write it to settings'
: 'the rounds recorded default';
console.log(`BUILD PATH: ${a.buildPath} (${origin}). ${a.buildPath === 'comp'
? 'Comp-led: the chosen cards 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 */ }
}
@@ -169,6 +177,11 @@ 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({
@@ -181,11 +194,12 @@ if (hasFlag('schema')) {
{ 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 verdicts 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 "IMPECCABLES 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 challengers 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 cards 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 <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
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 verdicts 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 "IMPECCABLES 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 challengers 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 cards 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 <value> 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/settings.json, or a PRODUCT.md standing commitment as fallback) 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. Offer the toggle only when image generation exists. 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);
}
@@ -212,6 +226,13 @@ if (hasFlag('wait')) {
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 cards 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);
@@ -307,6 +328,12 @@ else raw = fs.readFileSync(0, 'utf8');
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;
function loadRound(json) {
const parsed = JSON.parse(json);
@@ -352,6 +379,10 @@ function loadRound(json) {
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;
}
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
@@ -368,6 +399,13 @@ function page() {
// no generation luck can distort.
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
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
@@ -377,7 +415,7 @@ function page() {
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) ? null : option.compSrc;
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;
@@ -497,7 +535,7 @@ function page() {
};
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
const cards = options.map((option, index) => `
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}"${codeLed && option.compSrc && !demoted(option) ? ` data-comp-slot="${esc(option.compSrc)}"` : ''}>
<div class="card-inner">
<div class="face front${index === 0 ? ' lead' : ''}${(media(option) || wire(option)) ? '' : ' text-only'}">
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
@@ -781,6 +819,17 @@ function page() {
footer { position: sticky; bottom: 0; z-index: 10; width: 100vw; margin: 1.2rem calc(50% - 50vw) 0; padding: .7rem var(--page-inset) calc(.7rem + env(safe-area-inset-bottom, 0px)); display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: oklch(7% 0.006 95 / 0.82); backdrop-filter: blur(10px); border-top: 1px solid var(--ks-rule); }
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
#steer:focus { outline: none; border-color: var(--ks-patina); }
/* Build-path toggle: a workflow preference surfaced as a quiet segmented
control, its trade stated in one line that changes with the selection.
The default comes from the payload (settings); flipping binds this
session only, and the agent learns about a code-to-comp flip live. */
#build-path { display: flex; flex-direction: column; gap: 4px; }
.bp-switch { display: inline-flex; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; align-self: flex-start; }
.bp-opt { font-family: var(--ks-mono); font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; padding: 7px 12px; background: transparent; border: 0; color: var(--ks-text-faint); cursor: pointer; transition: color .2s ease, background-color .2s ease; }
.bp-opt + .bp-opt { border-left: 1px solid var(--ks-rule); }
.bp-opt.active { color: var(--ks-dark-ink); background: var(--ks-kinpaku-rich); }
.bp-opt:not(.active):hover { color: var(--ks-text); }
.bp-note { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .04em; color: var(--ks-text-faint); max-width: 21rem; line-height: 1.5; }
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
.reroll-btn svg { width: 15px; height: 15px; }
@@ -833,6 +882,13 @@ function page() {
</main>
<footer>
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
${buildPath?.toggle ? `<div id="build-path" data-default="${buildPath.value}">
<div class="bp-switch" role="radiogroup" aria-label="Build path">
<button type="button" class="bp-opt" data-bp="comp" role="radio" aria-checked="false">Comp first</button>
<button type="button" class="bp-opt" data-bp="code" role="radio" aria-checked="false">Code first</button>
</div>
<p class="bp-note" data-bp-note></p>
</div>` : ''}
${(() => {
if (!payload.reroll) return '';
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
@@ -928,7 +984,7 @@ function page() {
// stand-in is labeled as such, and polling continues so the real comp
// still swaps in whenever it arrives. Progress anywhere resets patience.
const landTracker = { last: Date.now() };
document.querySelectorAll('.media.comp-pending').forEach(m => {
const pollComp = (m) => {
const url = m.dataset.comp;
const img = m.querySelector('img.comp');
const note = m.querySelector('.comp-note');
@@ -949,6 +1005,8 @@ function page() {
m.appendChild(label);
};
const tryLoad = () => {
// A slot the user flipped back out of leaves the DOM; let its loop die.
if (!m.isConnected) { clearInterval(tick); return; }
const probe = new Image();
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
probe.onerror = () => {
@@ -959,7 +1017,65 @@ function page() {
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
};
tryLoad();
});
};
document.querySelectorAll('.media.comp-pending').forEach(pollComp);
// Build-path toggle: the default is the round's recorded preference and
// flipping binds this session only. Flipping code to comp swaps every
// reserve slot (data-comp-slot) to its shimmer and tells the server, so
// the waiting agent starts generating; flipping back is free: pending
// slots return to their wireframes, a comp that already landed stays.
const bp = document.getElementById('build-path');
if (bp) {
const notes = {
comp: 'An image sets the bar first and the build must match it. Bolder composition; comps render before code.',
code: 'Code builds directly; the ambition is written into the contract and audited at the finish. Leaner, faster.',
};
const noteEl = bp.querySelector('[data-bp-note]');
let current = bp.dataset.default;
const set = (value) => {
current = value;
bp.querySelectorAll('.bp-opt').forEach(b => {
const on = b.dataset.bp === value;
b.classList.toggle('active', on);
b.setAttribute('aria-checked', String(on));
});
if (noteEl) noteEl.textContent = notes[value];
};
set(current);
const enterComp = () => {
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
const front = card.querySelector('.face.front');
if (!front || front.querySelector('.media.comp-pending') || front.querySelector('.media img.comp:not([hidden])')) return;
const m = document.createElement('div');
m.className = 'media comp-pending';
m.dataset.comp = card.dataset.compSlot;
m.innerHTML = '<div class="shimmer"><span class="comp-note">rendering&hellip;</span></div><img class="comp" alt="" hidden>';
const wireEl = front.querySelector('.media.wire');
if (wireEl) { wireEl.hidden = true; front.insertBefore(m, wireEl); }
else { front.classList.remove('text-only'); front.insertBefore(m, front.querySelector('.body')); }
pollComp(m);
});
};
const exitComp = () => {
document.querySelectorAll('.card[data-comp-slot]').forEach(card => {
const front = card.querySelector('.face.front');
const pending = front?.querySelector('.media.comp-pending');
if (!pending) return; // landed comps stay; they exist either way
pending.remove();
const wireEl = front.querySelector('.media.wire');
if (wireEl) wireEl.hidden = false;
else if (!front.querySelector('.media')) front.classList.add('text-only');
});
};
bp.querySelectorAll('.bp-opt').forEach(b => b.addEventListener('click', () => {
const value = b.dataset.bp;
if (value === current) return;
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
}));
}
// A declared image that never loads (missing catalog asset, offline shell)
// must not sit as a dark void: the slot collapses to the card's own
@@ -1166,6 +1282,26 @@ const server = http.createServer((req, res) => {
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; });
@@ -1187,6 +1323,7 @@ const server = http.createServer((req, res) => {
...(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) } : {}),
});
if (detachedKey) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });