From 285ed7ef784ddb2cc3f2727b8b82b64a599e02e3 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 21 Jul 2026 16:50:21 -0700 Subject: [PATCH] serve-question: schema discoverability and a non-blocking mode Paul's two concerns with the blocking design. --schema prints the exact payload example so the model never guesses the shape (new-work.md points at it). And harnesses that cannot leave a shell blocked (or cannot open a browser while blocked) get a two-phase path: --start daemonizes the server and returns the URL plus a key immediately, --wait polls for the answer with exit 3 meaning ask again, exit 2 meaning the server died, and --stop for cleanup. The browser open happens from the detached server process, so it works even when the agent thread is short-lived. State lives under .impeccable/questions/. Co-Authored-By: Claude Fable 5 --- skill/reference/new-work.md | 2 +- skill/scripts/serve-question.mjs | 100 ++++++++++++++++++++++++++++++- tests/serve-question.test.mjs | 22 +++++++ 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 640b88fa7..46db037b8 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -44,7 +44,7 @@ The script assigns which structure gets built: your top-ranked structure is what 2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; the audience's world is larger than that, so dig until the list spans at least three families. 3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience. 4. Run `node {{scripts_path}}/concept-seed.mjs --scope direction --mode ` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. -5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. In an attended session where a browser can open, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled, then run `node {{scripts_path}}/serve-question.mjs --payload `; it prints the page URL, opens the browser, blocks until the user chooses, and prints their ANSWER as JSON. Fall back to the structured question tool when no browser can open. +5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. In an attended session where a browser can open, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled, then run `node {{scripts_path}}/serve-question.mjs --payload ` (run it with `--schema` first for the exact payload shape); it prints the page URL, opens the browser, blocks until the user chooses, and prints their ANSWER as JSON. When the harness cannot leave a shell blocked, use `--start` to daemonize (it prints the URL and a key immediately) and collect with `--wait --key `, repeating on exit code 3. Fall back to the structured question tool when no browser can open. Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product. diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index 369654f01..12c0fa8d6 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -35,12 +35,22 @@ * or a freshly generated mock). 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. + * --wait --key K [--poll 60] poll for the answer: exit 0 + ANSWER line, + * exit 3 WAITING (run --wait again), exit 2 server gone. + * --stop --key K kill a daemonized question. + * * node serve-question.mjs --payload question.json [--timeout 900] [--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'; function arg(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); @@ -53,6 +63,78 @@ const hasFlag = (name) => process.argv.includes(`--${name}`); const payloadPath = arg('payload'); const timeoutSec = Number(arg('timeout', '900')); 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`); + +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', body: 'Why it fits, the first viewport, the honest risk.', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' }, + { id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' }, + ], + reroll: true, + steer: true, + }, null, 2)); + console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.'); + 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)); + const alive = () => { + try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); return true; } + catch { return false; } + }; + while (Date.now() < deadline) { + if (answered()) break; + if (!alive()) { + console.log('serve-question: the question server is gone with no answer'); + process.exit(2); + } + await new Promise((r) => setTimeout(r, 1000)); + } + if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); } + console.log(`ANSWER: ${fs.readFileSync(answerFile(key), 'utf8').trim()}`); + try { fs.rmSync(answerFile(key)); fs.rmSync(stateFile(key)); } catch { /* already cleaned */ } + 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('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); + const child = spawn(process.execPath, [ + fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key, + '--timeout', String(timeoutSec), ...(hasFlag('no-open') ? ['--no-open'] : []), + ], { detached: true, stdio: 'ignore' }); + child.unref(); + 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))) { console.error('serve-question: server failed to start'); 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(`Collect the answer with: node ${fileURLToPath(import.meta.url)} --wait --key ${key}`); + process.exit(0); +} let raw; if (payloadPath) raw = fs.readFileSync(payloadPath, 'utf8'); @@ -169,7 +251,14 @@ const server = http.createServer((req, res) => { res.end('{"ok":true}'); let parsed = {}; try { parsed = JSON.parse(body); } catch { /* empty steer */ } - console.log(`ANSWER: ${JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' })}`); + const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' }); + const detachedKey = hasFlag('detached-serve') ? arg('key') : null; + if (detachedKey) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(answerFile(detachedKey), answer + '\n'); + } else { + console.log(`ANSWER: ${answer}`); + } setTimeout(() => process.exit(0), 150); }); return; @@ -180,8 +269,13 @@ const server = http.createServer((req, res) => { server.listen(portArg, '127.0.0.1', () => { const { port } = server.address(); const url = `http://127.0.0.1:${port}/`; - console.log(`QUESTION URL: ${url}`); - console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); + 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')) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ } diff --git a/tests/serve-question.test.mjs b/tests/serve-question.test.mjs index 67b5afccb..563fd71af 100644 --- a/tests/serve-question.test.mjs +++ b/tests/serve-question.test.mjs @@ -68,6 +68,28 @@ describe('serve-question', () => { assert.match(read(), /"optionId":"reroll"/); }); + it('start/wait cycle: daemonize, poll WAITING, then collect the answer', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-')); + const payloadPath = path.join(dir, 'q.json'); + writeFileSync(payloadPath, JSON.stringify(PAYLOAD)); + const run = (args) => new Promise((resolve) => { + const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }); + let out = ''; + child.stdout.on('data', (chunk) => { out += chunk; }); + child.on('exit', (code) => resolve({ code, out })); + }); + const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'tk']); + assert.equal(started.code, 0); + const url = started.out.match(/QUESTION URL: (\S+)/)?.[1]; + assert.ok(url, started.out); + const waiting = await run(['--wait', '--key', 'tk', '--poll', '1']); + assert.equal(waiting.code, 3); + await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'assigned', steer: '' }) }); + const collected = await run(['--wait', '--key', 'tk', '--poll', '5']); + assert.equal(collected.code, 0); + assert.match(collected.out, /"optionId":"assigned"/); + }); + it('rejects an empty payload', async () => { const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-')); const payloadPath = path.join(dir, 'q.json');