diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index c488a4e57..640b88fa7 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. +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. 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. @@ -74,7 +74,7 @@ If the work establishes durable strategy for a route or artifact, read its exist Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it. -When native image generation is available and words are not enough to judge the choice, use [codex.md](codex.md) to mock the direction before committing. The mock is a selection aid, not authority. +Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority. For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation. diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 598871f94..9892b4b84 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1127,6 +1127,7 @@ async function cli() { appendSurfaceBriefContext(parts, ctx); parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); appendDetectorFallback(parts, ctx); + appendImageGenDirective(parts); if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { parts.push(buildMissingTargetDirective()); } @@ -1141,6 +1142,7 @@ async function cli() { appendSurfaceBriefContext(parts, ctx); parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); appendDetectorFallback(parts, ctx); + appendImageGenDirective(parts); if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { parts.push(buildMissingTargetDirective()); } @@ -1243,6 +1245,20 @@ function automaticHookMode(ctx) { return 'none'; } + +// 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; generate-image.mjs states cost before use. +function appendImageGenDirective(parts) { + if (!process.env.OPENAI_API_KEY) return; + const scriptsPath = path.dirname(fileURLToPath(import.meta.url)); + parts.push([ + 'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:', + `\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out \` (gpt-image-2, billed to the user's key; say so before the first render).`, + 'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.', + ].join(' ')); +} + // reference/craft-floor.md carries the detector-blind reflexes on every build, // so the only gap left here is the mechanical pass. A hook covers it, per-edit // or Stop; a session without one has to run the detector by hand. The detector diff --git a/skill/scripts/generate-image.mjs b/skill/scripts/generate-image.mjs new file mode 100644 index 000000000..17501e3dc --- /dev/null +++ b/skill/scripts/generate-image.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * API image generation fallback: renders a mock or world board with the + * user's own OpenAI key when the harness has no native image generation. + * + * context.mjs reports availability (it checks OPENAI_API_KEY); harness-native + * generation always wins when present. This uses gpt-image-2 and spends the + * user's API credit (roughly $0.05-0.25 per image at default quality), so the + * skill states that before the first call in a session. + * + * node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium] + * node generate-image.mjs --prompt-file prompt.txt --out mock.png + */ +import fs from 'node:fs'; + +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 key = process.env.OPENAI_API_KEY; +if (!key) { + console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.'); + process.exit(1); +} +const promptFile = arg('prompt-file'); +const prompt = promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt'); +const out = arg('out'); +if (!prompt || !out) { + console.error('generate-image: --prompt (or --prompt-file) and --out are required.'); + process.exit(1); +} +const size = arg('size', '1536x1024'); +const quality = arg('quality', 'medium'); + +const response = await fetch('https://api.openai.com/v1/images/generations', { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }), +}); +if (!response.ok) { + console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`); + process.exit(1); +} +const json = await response.json(); +const b64 = json?.data?.[0]?.b64_json; +if (!b64) { + console.error('generate-image: no image in response'); + process.exit(1); +} +fs.writeFileSync(out, Buffer.from(b64, 'base64')); +console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key)`); diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs new file mode 100644 index 000000000..369654f01 --- /dev/null +++ b/skill/scripts/serve-question.mjs @@ -0,0 +1,195 @@ +#!/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 or closed without answering. + * + * 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 + * "body": "why it fits, first viewport, risk ...", // optional, plain text + * "hero": "https://... or /abs/path.webp", // optional image + * "board": "https://... or /abs/path.webp" // optional secondary image + * }, ... + * ], + * "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"}) + * "steer": true // adds a free-text steer field returned with any answer + * } + * + * Options render as large cards: hero render first when present (the dealt + * catalog worlds already have cards; grounded directions may present text-only + * or a freshly generated mock). Local image paths are served by this server; + * nothing is uploaded anywhere. + * + * 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'; + +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}`); + +const payloadPath = arg('payload'); +const timeoutSec = Number(arg('timeout', '900')); +const portArg = Number(arg('port', '0')); + +let raw; +if (payloadPath) raw = fs.readFileSync(payloadPath, 'utf8'); +else raw = fs.readFileSync(0, 'utf8'); +const payload = JSON.parse(raw); +if (!payload || !Array.isArray(payload.options) || payload.options.length === 0) { + console.error('serve-question: payload needs an options array'); + process.exit(1); +} + +// Local images are served through /img//; remote URLs pass through. +const localImages = []; +function 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}`; +} +const options = payload.options.map((option) => ({ + ...option, + heroSrc: imageSrc(option.hero), + boardSrc: imageSrc(option.board), +})); + +const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +function page() { + const cards = options.map((option, index) => ` +
+ ${option.kicker ? `${esc(option.kicker)}` : ''} + ${option.heroSrc ? `` : '
'} +
+

${esc(option.label)}

+ ${option.lineage ? `

${esc(option.lineage)}

` : ''} + ${option.body ? `

${esc(option.body)}

` : ''} + ${option.boardSrc ? `
design-system board
` : ''} + +
+
`).join('\n'); + return ` + +${esc(payload.title || 'impeccable · decision')} + +
+
impeccable
+

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

+ ${payload.question ? `

${esc(payload.question)}

` : ''} +
+
${cards}
+
+ ${payload.steer ? '' : ''} + ${payload.reroll ? '' : ''} +
+`; +} + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(page()); + return; + } + const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)$/); + if (imageMatch) { + const abs = localImages[Number(imageMatch[1])]; + if (!abs) { res.writeHead(404); res.end(); return; } + const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg'; + res.writeHead(200, { 'content-type': type }); + fs.createReadStream(abs).pipe(res); + 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 */ } + console.log(`ANSWER: ${JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' })}`); + 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}/`; + 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 */ } + } + if (timeoutSec > 0) { + setTimeout(() => { + console.log('serve-question: timed out with no answer'); + process.exit(2); + }, timeoutSec * 1000).unref?.(); + } +}); diff --git a/tests/serve-question.test.mjs b/tests/serve-question.test.mjs new file mode 100644 index 000000000..67b5afccb --- /dev/null +++ b/tests/serve-question.test.mjs @@ -0,0 +1,81 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPT = path.join(ROOT, 'skill', 'scripts', 'serve-question.mjs'); + +function startServer(payload, extraArgs = []) { + const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-')); + const payloadPath = path.join(dir, 'q.json'); + writeFileSync(payloadPath, JSON.stringify(payload)); + const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath, '--no-open', '--timeout', '30', ...extraArgs], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + return new Promise((resolve, reject) => { + let out = ''; + const timer = setTimeout(() => reject(new Error(`no URL in output: ${out}`)), 10000); + child.stdout.on('data', (chunk) => { + out += chunk; + const match = out.match(/QUESTION URL: (http:\/\/127\.0\.0\.1:\d+\/)/); + if (match) { clearTimeout(timer); resolve({ child, url: match[1], read: () => out }); } + }); + }); +} + +const PAYLOAD = { + title: 'Choose the visual world', + question: 'The roll assigned Fillmore Handbill.', + options: [ + { id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 psychedelic handbills' }, + { id: 'challenger-1', label: 'Teletext Service', body: 'block-mosaic broadcast pages' }, + ], + reroll: true, + steer: true, +}; + +describe('serve-question', () => { + it('serves the page, records the answer, prints ANSWER, exits 0', async () => { + const { child, url, read } = await startServer(PAYLOAD); + const html = await (await fetch(url)).text(); + assert.match(html, /Fillmore Handbill/); + assert.match(html, /THE ROLL/); + assert.match(html, /Re-roll/); + const post = await fetch(`${url}answer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ optionId: 'assigned', steer: 'warmer palette' }), + }); + assert.equal(post.status, 200); + const code = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(code, 0); + assert.match(read(), /ANSWER: \{"optionId":"assigned","steer":"warmer palette"\}/); + }); + + it('re-roll answers round-trip with their own id', async () => { + const { child, url, read } = await startServer(PAYLOAD); + await fetch(`${url}answer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ optionId: 'reroll', steer: '' }), + }); + const code = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(code, 0); + assert.match(read(), /"optionId":"reroll"/); + }); + + it('rejects an empty payload', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-')); + const payloadPath = path.join(dir, 'q.json'); + writeFileSync(payloadPath, JSON.stringify({ options: [] })); + const code = await new Promise((resolve) => { + const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath, '--no-open'], { stdio: 'ignore' }); + child.on('exit', resolve); + }); + assert.equal(code, 1); + }); +});