diff --git a/.agents/skills/done-done-verify-first-draft/SKILL.md b/.agents/skills/done-done-verify-first-draft/SKILL.md index 6037787..0700787 100644 --- a/.agents/skills/done-done-verify-first-draft/SKILL.md +++ b/.agents/skills/done-done-verify-first-draft/SKILL.md @@ -51,6 +51,7 @@ Harness: A shell runs the CLI and curl for the url. A browser tool opens the url - [Logo mode: concepts from the brief (wordmark-led, abstract, symbol; distinct base shapes; letter + object dropped), self-critique at 16 and 32 px, brand sheets with kind, idea and palette, checked SVG files and the app icon set](features/logo-mode.md): Run `first-draft-axi logo "" --out tea.html --options 3` (Chrome and an image-capable model required); deterministic `test/logo.test.mjs`; nonpaid whole-viewer, concept, critique and icon-set capture of a real run via `FD_LOGO_SOURCE= done-done run logo-recapture` (`src/logo.mjs`, `src/logo-concepts.mjs`, `src/make.mjs`, `src/cli.mjs`, `journeys/logo.mjs`) - [Authenticated engines are required](features/ai-engine-required.md): Offline no-credential refusal through `test/engine-required.test.mjs` and independent make/judge CLI integrations through `test/critic.test.mjs`; one real normal-HOME `make --options 1 --first-view` must name distinct generator/judge engines and models. No fixture or self-assessment result counts as live proof. - [The independent critic and local reference library](features/make-critic.md): `npm test` retains one real-CLI make/independent-judge integration and one retained-output `judge` integration in `test/critic.test.mjs`; it no longer covers the former detailed capture, retry, and refusal matrix; nonpaid sealed report via `done-done run critic`; live retained assessment via `first-draft-axi judge "" --plain ` (`src/critic.mjs`, `src/human-side.mjs`, `src/judge.mjs`, `src/reference-library.mjs`, `src/reference-library.json`, `src/reference-capture.mjs`, `src/reference-network.mjs`, `src/cli.mjs`, `src/render-check.mjs`, `journeys/critic.mjs`) +- [A clickable option stays clickable: its script exports, a real press is judged, and its source is readable beside it](features/clickable-option-export.md): Make from a brief that asks for behaviour (a row opens, a filter narrows) and read the option html (inline ` diff --git a/journeys/click.mjs b/journeys/click.mjs new file mode 100644 index 0000000..6d86db9 --- /dev/null +++ b/journeys/click.mjs @@ -0,0 +1,137 @@ +// Nonpaid real-CLI proof that a clickable option stays clickable: an inbox brief whose rows and filter are wired by an inline script -> +// make exports the option with its script, loading nothing -> the host presses one of its controls with a real mouse event in a headless +// browser and keeps a frame sequence across it, encoded into click-390.mp4 and click-1280.mp4 -> the independent judge is asked about +// behaviour and returns a verdict -> a control that +// changes nothing is judged and refused -> a drawn App.jsx option keeps its source readable beside it -> the view serves the clickable page +// in its sandy frame and a real click on what "Open live" opens still works. Local stand-ins answer the model and the judge, so scores are +// synthetic, never design quality. +import '../test/fixtures/provider-hooks.mjs'; +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { renderCheck } from '../src/render-check.mjs'; +import { standIn } from '../test/fixtures/critic-model.mjs'; +import { installJudge } from '../test/fixtures/judge-cli.mjs'; +const root = new URL('..', import.meta.url).pathname, out = process.env.DONE_DONE_EVIDENCE_ROOT ?? process.argv[2]; +if (!out) throw Error('Give an evidence folder'); +mkdirSync(out, { recursive: true }); +const BRIEF = 'An inbox app: pick a message from the list to read it, and filter the list to unread'; +const ffmpeg = (...args) => promisify(execFile)('ffmpeg', args, { maxBuffer: 32_000_000 }); +// The press is a clip, not only two stills: the frame sequence across it (render-check's PRESS_AT) is encoded into a short mp4 per width. +// Refuse before any work when ffmpeg is not on PATH, rather than after the CLI has made the option. +try { await ffmpeg('-version'); } catch { throw Error('the click clips need ffmpeg: click-390.mp4 and click-1280.mp4 cannot be encoded without it. Install ffmpeg on PATH and run the journey again'); } +const encode = async (width, sequence) => { + const dir = mkdtempSync(join(tmpdir(), `fd-click-seq-${width}-`)), list = join(dir, 'frames.txt'); + sequence.forEach(({ dataUrl }, i) => writeFileSync(join(dir, `f-${String(i).padStart(3, '0')}.jpg`), Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'))); + // Each frame keeps the time it was really taken, so the clip plays the change at the pace a person saw it. + writeFileSync(list, `${sequence.map(({ at_ms }, i) => `file 'f-${String(i).padStart(3, '0')}.jpg'\nduration ${((sequence[i + 1]?.at_ms ?? at_ms + 200) - at_ms) / 1000}`).join('\n')}\n`); + const file = join(out, 'frames', `click-${width}.mp4`); + try { + await ffmpeg('-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-an', '-vf', "fps=15,scale='min(1280,iw)':-2", '-c:v', 'libx264', '-preset', 'slow', '-crf', '30', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', file); + return { file, bytes: statSync(file).size }; + } finally { rmSync(dir, { recursive: true, force: true }); } +}; +// What the stand-in model writes: an inbox whose rows open their message and whose filter narrows the list, both wired by an inline script. +const inbox = (title, wired = true) => `${title} +

+ + +
+

Design review Thursday

Can we walk the board flows at 10:00?

+`; +// A page that looks clickable and is not: the script is there, the control is wired to nothing. +const dead = inbox('Inbox', false); // the same page with its filter wired to nothing +const dir = mkdtempSync(join(tmpdir(), 'fd-click-')); +installJudge(dir); +const seen = [], server = await standIn(seen, [[7, 6], [7, 6], [9, 6]], false, () => {}, () => inbox('Inbox')); +const drawnServer = await standIn(seen, [[7, 6]], true, () => {}, () => inbox('Inbox')); +let view; +try { + const env = { ...process.env, PATH: `${dir}:${process.env.PATH}`, CI: 'true', HOME: dir, XDG_CONFIG_HOME: dir, XDG_DATA_HOME: dir, TMPDIR: dir, FIRST_DRAFT_HOME: dir, OPENCODE_API_KEY: 'synthetic', FD_JUDGE_SCORES: '[[7,7],[9,7],[9,7]]', + FIRST_DRAFT_TEST_BYOKIT_BASE: `http://127.0.0.1:${server.address().port}` }; + const cli = (e) => async (...args) => JSON.parse((await promisify(execFile)(process.execPath, [join(root, 'src/cli.mjs'), ...args], { env: e, cwd: dir, maxBuffer: 64_000_000 })).stdout.trim().split('\n').at(-1)); + const run = cli(env), runDrawn = cli({ ...env, FIRST_DRAFT_TEST_BYOKIT_BASE: `http://127.0.0.1:${drawnServer.address().port}` }); // the engine make talks to the stand-in that writes App.jsx + await run('init', '--agent', 'byokit:opencode-go', '--model', 'muse-spark-1.3-contributor'); + + // 1. The clickable brief in: the export keeps the option's own script and loads nothing, in a document that can run it. + const { made } = await run('make', BRIEF, '--out', join(dir, 'inbox.html'), '--options', '1'); + const [file] = made.files, html = readFileSync(file.file, 'utf8'); + assert.match(html, /