From d9a155be8c89ccb62bdb05df1a30aec3d743f12f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 16 Aug 2026 13:50:59 -0700 Subject: [PATCH] Gates that cannot be talked past: metric hardening, one start command, uncovered-ink refusal From a forensics pass over twelve runs and two adversarial passes over the metrics: - comp-diff: detail is signed and penalizes invented energy; regions with structure under 0.3 (or painted regions under 0.45 / added detail over 0.4) are contradicted whatever the mean says; palette ramp tightened; region crops inherit the whole-image best translation so a shifted page is not eight contradicted regions. - hero gate: fails on any contradicted plate/image/text region (chrome and controls keep the one-third allowance), on a capture that is not the comp's frame, on a palette that is not the comp's, and on an organic clip-path drawn inside a raster region's box. - plates gate: scored against the comp crop with overlapping text/chrome painted out (comp-spec plateReference; generate-image uses it too), with a structure floor and an added-detail veto; the real plate passes, noise, mirrors, mosaics, and other regions do not. - comp-spec refuses a regions file that leaves comp ink unnamed. - The direction-choice ping is folded into build-phase.mjs start --direction --kind; the roll writes .impeccable/build/pending.json and context.mjs / detect.mjs report COMP_ROUND_OPEN until the hero gate passes. A code-led config makes start print the contract step and stop. AI-assisted (Claude). Co-Authored-By: Claude --- skill/reference/new-work.md | 5 +- skill/reference/visualize.md | 2 +- skill/scripts/build-phase.mjs | 119 ++++++++++++++++++++++++++-- skill/scripts/comp-diff.mjs | 41 +++++++++- skill/scripts/comp-spec.mjs | 66 ++++++++++++++- skill/scripts/concept-seed.mjs | 36 ++++++--- skill/scripts/context.mjs | 21 +++++ skill/scripts/detect.mjs | 9 +++ skill/scripts/generate-image.mjs | 7 +- skill/scripts/lib/image-metrics.mjs | 14 ++-- tests/build-phase.test.mjs | 12 ++- tests/concept-seed.test.mjs | 6 +- 12 files changed, 302 insertions(+), 36 deletions(-) diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index a930f6dd8..b1b1bae0d 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -96,11 +96,12 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`node {{scripts_path}}/build-phase.mjs start --comp ` +`node {{scripts_path}}/build-phase.mjs start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` (every script below lives under `{{scripts_path}}/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open): -1. **spec.** Measure the comp: `comp-spec.mjs --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws), and run `comp-spec.mjs --comp --regions `. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user chose an icon library), and genuine defects in the comp such as spelling errors. +0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened. +1. **spec.** Measure the comp: `comp-spec.mjs --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws), and run `comp-spec.mjs --comp --regions `. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user chose an icon library), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: the region regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path. `generate-image.mjs --plate ` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop `) as its input image and `comp-spec.mjs --plate-prompt ` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. 3. **hero.** Build only the first viewport, at the comp's own dimensions, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, advance once so the gate reads the material, then lay the semantic layer over the plates from the spec's palette and boxes and advance again. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no region missing. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. diff --git a/skill/reference/visualize.md b/skill/reference/visualize.md index d24fd1c04..6772d711d 100644 --- a/skill/reference/visualize.md +++ b/skill/reference/visualize.md @@ -27,7 +27,7 @@ Do not begin code until the user approves a direction or explicitly delegates th This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats comp-round comps with no recorded approval as a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own. -After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build. +After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief, and it is what `build-phase.mjs advance` reads to close the comps phase. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build. ## After approval: the comp becomes a spec diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index 14ea73f98..3fba37724 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -35,9 +35,14 @@ * closing records that file as the state's comp. * spec -> spec.json exists and has >= 1 region * plates -> every region with medium raster has its plate file, decodable, - * at least 2x the comp region's pixel size in width, and the - * plate scores >= PLATE_MIN against the comp crop (comp-diff, - * detail-weighted). A missing or thin plate names itself. + * at least 1.5x the comp region's pixel width (textures + * exempt), and reads as the region against the masked comp + * crop: structure >= PLATE_STRUCTURE_MIN and comp-diff overall + * >= PLATE_MIN (textures: palette + grain only). Structure is + * the floor because it is what a wrong-but-busy plate cannot + * fake: noise, a mirror, a mosaic, another region all keep + * the palette and the energy and lose structure. A missing + * or thin plate names itself. * hero -> every plate is referenced by a source file (the artifact * named at start, else a bounded walk of the project), and * .impeccable/review/hero-repro.png exists and comp-diff overall @@ -61,14 +66,15 @@ import { decodePng } from './lib/png.mjs'; const require = createRequire(import.meta.url); import { crop } from './lib/raster.mjs'; import { compare, verdictFor } from './comp-diff.mjs'; -import { SPEC_PATH, BUILD_DIR, loadSpec } from './comp-spec.mjs'; +import { SPEC_PATH, BUILD_DIR, loadSpec, plateReference } from './comp-spec.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); export const STATE_PATH = path.join(BUILD_DIR, 'state.json'); export const PHASES = ['comps', 'spec', 'plates', 'hero', 'sections', 'motion', 'responsive', 'review']; export const MOCKS_DIR = path.join('.impeccable', 'mocks'); export const HERO_MIN = 0.72; -export const PLATE_MIN = 0.5; +export const PLATE_MIN = 0.4; +export const PLATE_STRUCTURE_MIN = 0.4; export const HERO_REPRO = path.join('.impeccable', 'review', 'hero-repro.png'); function arg(name, fallback = null) { @@ -80,6 +86,41 @@ function arg(name, fallback = null) { const flag = (name) => process.argv.includes(`--${name}`); const now = () => new Date().toISOString(); +/** The recorded build path (config.local.json over config.json), or null. */ +export function readBuildPath(cwd = process.cwd()) { + let value = null; + for (const name of ['config.json', 'config.local.json']) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf8')); + if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') value = raw.buildPath; + } catch { /* absent */ } + } + return value; +} + +/** + * Whether a direction was dealt and the build never started, or started and + * stopped before the hero gate: the condition context.mjs and detect.mjs + * report as COMP_ROUND_OPEN when page code exists. Returns null when the + * build path is code-led (no round owed) or nothing is pending. + */ +export function compRoundOpen(cwd = process.cwd()) { + const buildPath = readBuildPath(cwd); + if (buildPath === 'code') return null; + const pending = path.join(cwd, BUILD_DIR, 'pending.json'); + const statePath = path.join(cwd, STATE_PATH); + if (fs.existsSync(pending) && !fs.existsSync(statePath)) return { reason: 'a direction was chosen (concept-seed rolled) but build-phase.mjs start never ran', pending }; + if (fs.existsSync(statePath)) { + try { + const st = JSON.parse(fs.readFileSync(statePath, 'utf8')); + const idx = PHASES.indexOf(st.phase); + if (idx !== -1 && idx <= PHASES.indexOf('hero') && st.phases?.comps?.status !== 'skipped' && st.phases?.comps?.status !== 'closed') return { reason: `build-phase is at ${st.phase}; the comps phase never closed`, state: statePath }; + if (idx !== -1 && idx <= PHASES.indexOf('hero')) return { reason: `build-phase is at ${st.phase}; the hero gate has not passed`, state: statePath }; + } catch { /* unreadable: say nothing */ } + } + return null; +} + export function loadState(statePath = STATE_PATH) { if (!fs.existsSync(statePath)) return null; return JSON.parse(fs.readFileSync(statePath, 'utf8')); @@ -172,10 +213,12 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) { if (!isTexture && img.width < minW) reasons.push(`plate ${file} is ${img.width}px wide; the comp region is ${r.px.w}px and a shipping plate needs at least ${Math.round(minW)}px. Regenerate at asset size, do not crop the comp.`); let score = null; if (comp) { - const ref = crop(comp, r.px.x, r.px.y, r.px.w, r.px.h); + const ref = plateReference(comp, spec, r); const res = compare({ comp: ref, build: img, align: 'cover', spec: null, kind: r.kind }); score = res.whole; const effective = isTexture ? 0.5 * score.color + 0.5 * Math.min(1, score.detail / 0.6) : score.overall; + if (!isTexture && score.detailAdded > 0.45) reasons.push(`plate ${file} carries detail the comp region ${r.id} does not have (added-detail ${(score.detailAdded * 100).toFixed(0)}% of cells): noise, grain, or a busier subject where the comp is calm. Regenerate from the crop reference; do not add texture the comp does not show.`); + if (!isTexture && score.structure < PLATE_STRUCTURE_MIN) reasons.push(`plate ${file} has structure ${(score.structure * 100).toFixed(0)}% against the comp region ${r.id}: the composition of the plate is not the region's (a different subject, orientation, or crop). Regenerate with comp-spec.mjs --crop ${r.id} as the reference image; a plate that only shares the palette and busyness is not this plate.`); if (effective < PLATE_MIN) reasons.push(`plate ${file} scores ${(effective * 100).toFixed(0)}% against the comp region ${r.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); it does not read as the same ${isTexture ? 'material' : 'region'}. Regenerate with the crop as --ref and the comp-spec plate prompt${isTexture ? ', or crop a clean patch of the comp region and tile it' : ''}.`); } plates.push({ id: r.id, file, status: 'ok', size: `${img.width}x${img.height}`, score: score ? score.overall : null }); @@ -220,6 +263,30 @@ export function unreferencedPlates(spec, artifact = null) { return missing; } +/** Organic clip-path findings whose selector's element the artifact places (by class/id name) on a raster region. Cheap heuristic: the finding's selector or the surrounding rule mentions the region id or its plate stem. */ +export function organicClipRegions(artifactFile, spec) { + let scan; + try { + const mod = require(path.join(HERE, '..', '..', 'cli', 'engine', 'rules', 'checks.mjs')); + scan = mod.scanCssTextForOrganicClipPath; + } catch { scan = null; } + if (!scan) return []; + let html = ''; + try { html = fs.readFileSync(artifactFile, 'utf8'); } catch { return []; } + const findings = scan(html); + if (!findings.length) return []; + const rasterRegions = (spec.regions || []).filter((r) => r.medium === 'raster'); + const out = []; + for (const f of findings) { + const sel = String(f.selector || '').toLowerCase(); + for (const r of rasterRegions) { + const stem = path.basename(r.plate || '', path.extname(r.plate || '')).toLowerCase(); + if ((sel && (sel.includes(r.id.toLowerCase()) || (stem && sel.includes(stem)))) || rasterRegions.length === 1) { out.push({ id: r.id, snippet: f.snippet }); break; } + } + } + return out; +} + export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, min = HERO_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'hero'), artifact = null } = {}) { if (!fs.existsSync(buildPath)) return { ok: false, reasons: [`no hero capture at ${buildPath}: screenshot the first viewport at the comp's own dimensions (${state.breakpoint || 'comp size'}) into that path`] }; const specForRefs = loadSpec(specPath); @@ -236,11 +303,32 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, let report; try { report = JSON.parse(res.stdout); } catch { return { ok: false, reasons: ['comp-diff produced no report'] }; } const reasons = []; + // The capture must be the comp's own frame: a 1440-wide capture of a + // 1536x1024 comp is a different composition before anything is compared. + const [cw, ch] = String(report.compSize || '').split('x').map(Number); + const [bw, bh] = String(report.buildSize || '').split('x').map(Number); + if (cw && ch && bw && bh) { + const compAspect = cw / ch, buildAspect = bw / bh; + if (bw < cw * 0.9 || Math.abs(buildAspect - compAspect) / compAspect > 0.08) reasons.push(`hero capture is ${bw}x${bh}; the comp is ${cw}x${ch}. Capture the first viewport at the comp's own dimensions (viewport ${cw}x${ch}, not full page) into ${buildPath}.`); + } if (report.overall < min) reasons.push(`hero overall ${(report.overall * 100).toFixed(0)}% < ${(min * 100).toFixed(0)}% (structure ${(report.scores.structure * 100).toFixed(0)}%, color ${(report.scores.color * 100).toFixed(0)}%, detail ${(report.scores.detail * 100).toFixed(0)}%)`); + if (report.scores.colorIntersection != null && report.scores.colorIntersection < 0.2) reasons.push(`the palette is not the comp's (color intersection ${(report.scores.colorIntersection * 100).toFixed(0)}%): comp ${(report.palette.comp || []).slice(0, 3).map((c) => c.hex).join(' ')} vs build ${(report.palette.build || []).slice(0, 3).map((c) => c.hex).join(' ')}. Use the spec's sampled palette values, not a rendition of them.`); const missing = report.regions.filter((r) => r.verdict === 'missing'); for (const r of missing) reasons.push(`region ${r.id} is missing (detail ${(r.score.detail * 100).toFixed(0)}%, structure ${(r.score.structure * 100).toFixed(0)}%): the comp shows material the build does not`); const contradicted = report.regions.filter((r) => r.verdict === 'contradicted'); - if (contradicted.length > Math.max(1, Math.floor(report.regions.length / 3))) reasons.push(`${contradicted.length} of ${report.regions.length} regions contradicted: ${contradicted.map((r) => r.id).join(', ')}`); + // A contradicted plate, image, or text region is the wrong page whatever + // the mean says; chrome and controls get the one-third allowance. + const directionContradicted = contradicted.filter((r) => r.kind === 'plate' || r.kind === 'image' || r.kind === 'text'); + for (const r of directionContradicted) reasons.push(`region ${r.id} (${r.kind}) is contradicted (structure ${(r.score.structure * 100).toFixed(0)}%, detail added ${(r.score.detailAdded * 100).toFixed(0)}%): ${r.kind === 'text' ? 'the composition of this text region differs from the comp; re-derive it from the spec box' : 'the plate here does not read as the comp region; regenerate it with the crop as reference (generate-image.mjs --plate ' + r.id + ') and place it at its box'}`); + const otherContradicted = contradicted.filter((r) => !directionContradicted.includes(r)); + if (otherContradicted.length > Math.max(1, Math.floor(report.regions.length / 3))) reasons.push(`${otherContradicted.length} of ${report.regions.length} regions contradicted: ${otherContradicted.map((r) => r.id).join(', ')}`); + // A CSS-drawn organic contour sitting on a raster region's box is the plate + // replaced by code, whatever the pixels score. + const artifactFile = artifact || state.artifact || null; + if (artifactFile && fs.existsSync(artifactFile) && specForRefs) { + const organic = organicClipRegions(artifactFile, specForRefs); + for (const r of organic) reasons.push(`artifact draws an organic clip-path (${r.snippet}) inside raster region ${r.id}'s box; that region ships as its plate, never as a polygon`); + } const worstRegions = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3); const regionDir = path.join(outDir, 'regions'); return { @@ -375,6 +463,23 @@ async function main() { const direction = arg('direction'); if (!comp && !direction) { console.error('build-phase: start needs --comp (comp already approved) or --direction (comp round still to run)'); process.exit(1); } if (comp && !fs.existsSync(comp)) { console.error(`build-phase: comp ${comp} does not exist`); process.exit(1); } + // The direction choice ping rides on start (see concept-seed.mjs): one + // command records the choice and opens the phases. Never fatal. + if (direction && arg('kind')) { + try { + const { pingChosen } = await import('./concept-seed.mjs'); + const sent = await pingChosen({ chosenId: arg('chosen') || undefined, key: direction, scope: 'direction', mode: arg('mode') || undefined, kind: arg('kind'), register: arg('register') || undefined }); + console.log(sent ? 'choice recorded' : 'choice ping skipped'); + } catch { console.log('choice ping skipped'); } + } + // Clear the roll's pending marker: the build has started. + try { fs.rmSync(path.join(BUILD_DIR, 'pending.json'), { force: true }); } catch { /* absent */ } + // Code-led: no phase machine to run; say what comes next and stop. + const buildPath = readBuildPath(); + if (direction && !comp && buildPath === 'code') { + console.log('CODE-LED (from .impeccable config): no comp round and no phase gates. Write the direction contract (reference/new-work.md section 5), build, and finish per section 7. The chosen decision comp, if any, rides to the finish review as the critique reference.'); + return; + } let breakpoint = arg('breakpoint'); if (!breakpoint && comp) { try { const i = decodePng(fs.readFileSync(comp)); breakpoint = `${i.width}x${i.height}`; } catch { /* leave null */ } } const existing = loadState(); diff --git a/skill/scripts/comp-diff.mjs b/skill/scripts/comp-diff.mjs index 03e879f1f..75c68f377 100644 --- a/skill/scripts/comp-diff.mjs +++ b/skill/scripts/comp-diff.mjs @@ -41,7 +41,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { decodePng, encodePng } from './lib/png.mjs'; import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs'; -import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors } from './lib/image-metrics.mjs'; +import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors, toGray, blurGray, ssimShifted } from './lib/image-metrics.mjs'; function arg(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); @@ -109,10 +109,36 @@ export function scorePair(a, b, kind = null) { }; } +/** Kinds that carry the direction: a wrong one is the wrong page, whatever the mean says. */ +export const DIRECTION_KINDS = new Set(['plate', 'image', 'text']); + +/** Best small global translation (build relative to comp), in pixels, by blurred-gray SSIM. */ +export function bestShift(comp, build, workWidth = 256) { + const h = Math.max(8, Math.round((comp.height / comp.width) * workWidth)); + const a = blurGray(toGray(resize(comp, workWidth, h)), 2); + const b = blurGray(toGray(resize(build, workWidth, h)), 2); + const maxShift = Math.max(2, Math.round(workWidth * 0.04)); + let best = { dx: 0, dy: 0, score: ssimShifted(a, b, 0, 0) }; + for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) { + for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) { + const sc = ssimShifted(a, b, Math.round(dx), Math.round(dy)); + if (sc > best.score + 0.01) best = { dx: Math.round(dx), dy: Math.round(dy), score: sc }; + } + } + const scale = comp.width / workWidth; + return { dx: Math.round(best.dx * scale), dy: Math.round(best.dy * scale), score: best.score }; +} + export function verdictFor(s, kind = null) { const painted = kind === 'plate' || kind === 'image' || kind === 'texture'; if (painted && s.detail < 0.5) return 'missing'; if (s.detail < 0.35 && s.structure < 0.6) return 'missing'; + // Structure is the one thing a wrong-but-busy region cannot fake: noise, + // a mirrored crop, a swapped column, a tile shuffle all keep color and + // energy and lose structure. Below the floor it is contradicted whatever + // the weighted mean says; painted regions with invented detail likewise. + if (s.structure < 0.3) return 'contradicted'; + if (painted && (s.structure < 0.45 || s.detailAdded > 0.4)) return 'contradicted'; if (s.overall >= 0.8) return 'match'; if (s.overall >= 0.6) return 'drift'; return 'contradicted'; @@ -202,8 +228,19 @@ export function renderRegionPair(compCrop, buildCrop, id, score) { } export function compare({ comp, build, spec = null, align = 'top', label = '', kind = null }) { - const aligned = alignBuild(comp, build, align); + let aligned = alignBuild(comp, build, align); const whole = scorePair(comp, aligned, kind); + // Region crops are taken at fixed boxes, so a small global offset (a + // taller masthead, a scrollbar) would read every thin region as + // contradicted while the whole-image search forgives it. Find the best + // global translation once and shift the aligned build by it before + // cropping regions; the whole score above stays as measured. + const shift = bestShift(comp, aligned); + if (shift.dx || shift.dy) { + const shifted = createImage(aligned.width, aligned.height, [255, 255, 255, 255]); + blit(shifted, aligned, -shift.dx, -shift.dy); + aligned = shifted; + } const regions = resolveRegions(comp, spec).map((r) => { const a = regionCrop(comp, r), b = regionCrop(aligned, r); const s = scorePair(a, b, r.kind); diff --git a/skill/scripts/comp-spec.mjs b/skill/scripts/comp-spec.mjs index 78813f047..ae566bcf5 100644 --- a/skill/scripts/comp-spec.mjs +++ b/skill/scripts/comp-spec.mjs @@ -24,9 +24,12 @@ * * Step 3, use it: * node comp-spec.mjs --print # compact spec for the build thread - * node comp-spec.mjs --crop exploded-plate --out tmp/plate-src.png [--scale 2] + * node comp-spec.mjs --crop exploded-plate --out tmp/plate-src.png [--scale 2] [--raw] * crops the region from the comp (reference for a plate regeneration; a - * crop is never a shipping asset, its resolution is comp grade) + * crop is never a shipping asset, its resolution is comp grade). For a + * raster region the crop has overlapping text/control/chrome regions + * painted out, matching what the plate prompt asks the generator to + * remove; --raw keeps them. * node comp-spec.mjs --plate-prompt exploded-plate # the regeneration prompt for that region * * comp-diff.mjs reads the same spec (`--spec`) so its region rows and this @@ -90,6 +93,33 @@ function energyOf(img) { } +/** + * Grid cells (10x10) that carry ink the regions do not name. A regions file + * that omits the comp's callouts, notes block, or parts table makes those + * elements invisible to every later gate (they are never 'missing' if they + * were never named), so the spec refuses to close over them. Texture and + * band regions do not cover: a full-bleed paper texture names the ground, + * not the drawing on it. + */ +export function uncoveredInkCells(comp, regions) { + const grid = detailGrid(comp, 10, 10, 512); + const cells = []; + // The ground's own energy (paper grain, gradient) is the quietest tenth of + // cells; ink is anything clearly above that. Median-relative thresholds + // fail on textured comps where every cell carries grain. + const energies = [...grid.cells].sort((a, b) => a - b); + const ground = energies[Math.floor(energies.length * 0.1)] || 0; + const threshold = Math.max(4, ground * 2.2, ground + 12); + for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) { + const e = grid.cells[r * 10 + c]; + if (e < threshold) continue; + const cx = (c + 0.5) / 10, cy = (r + 0.5) / 10; + const covered = regions.some((reg) => reg.kind !== 'texture' && reg.kind !== 'band' && cx >= reg.box.x && cx <= reg.box.x + reg.box.w && cy >= reg.box.y && cy <= reg.box.y + reg.box.h); + if (!covered) cells.push(`${COLS[c]}${r}`); + } + return cells; +} + export function measureRegions(comp, regionsInput, compPath) { const regions = []; const seen = new Set(); @@ -117,11 +147,16 @@ export function measureRegions(comp, regionsInput, compPath) { text: raw.text || null, }); } + const uncovered = uncoveredInkCells(comp, regions); + if (uncovered.length > 3 && !regionsInput.allowUncovered) { + throw new Error(`grid cells ${uncovered.join(', ')} carry ink no region names. Every element the comp shows must be in a region (text, control, chrome, or a plate) so its absence in the build can be measured; add regions for them, or set "allowUncovered": true in the regions file after confirming those cells are empty ground.`); + } return { tool: 'comp-spec', version: 1, createdAt: new Date().toISOString(), comp: compPath, + uncoveredInkCells: uncovered, compSize: { width: comp.width, height: comp.height }, aspect: r4(comp.width / comp.height), orientation: comp.width >= comp.height ? 'landscape' : 'portrait', @@ -143,6 +178,31 @@ export function autoRegions(comp) { const r4 = (v) => Math.round(v * 10000) / 10000; +/** + * The comp crop of a raster region, with every overlapping semantic region + * (text, control, chrome) painted out in the crop's own ground color. The + * plate prompt tells the generator to remove UI text and chrome, so a good + * plate must be scored against a crop that has them removed too; otherwise + * the plate loses structure points for obeying the spec. + */ +export function plateReference(comp, spec, region) { + const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); + const ground = (region.palette && region.palette[0] && hexToRgb(region.palette[0].hex)) || [255, 255, 255]; + for (const other of spec.regions || []) { + if (other.id === region.id || RASTER_KINDS.has(other.kind) || other.kind === 'band') continue; + const ox = Math.max(0, other.px.x - region.px.x), oy = Math.max(0, other.px.y - region.px.y); + const ox2 = Math.min(region.px.w, other.px.x + other.px.w - region.px.x), oy2 = Math.min(region.px.h, other.px.y + other.px.h - region.px.y); + if (ox2 <= ox || oy2 <= oy) continue; + fillRect(c, ox, oy, ox2 - ox, oy2 - oy, [...ground, 255]); + } + return c; +} + +function hexToRgb(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || ''); + return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null; +} + export function platePrompt(spec, region) { const world = spec.palette.slice(0, 3).map((c) => c.hex).join(', '); const kindLine = region.kind === 'texture' @@ -213,7 +273,7 @@ async function main() { const region = spec.regions.find((r) => r.id === arg('crop')); if (!region) { console.error(`comp-spec: no region ${arg('crop')}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); } const comp = decodePng(fs.readFileSync(spec.comp)); - let c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); + let c = region.medium === 'raster' && !flag('raw') ? plateReference(comp, spec, region) : crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); const scale = parseFloat(arg('scale', '1')); if (scale > 1) c = resize(c, c.width * scale, c.height * scale); const out = arg('out', path.join(BUILD_DIR, 'crops', `${region.id}.png`)); diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index 4fae925a4..991dc629b 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -614,16 +614,22 @@ rivals to your habitual layout, and keep only what makes this product clearer.${ assignment by deal order, so the dice still choose. Verdicts and donations apply between the challengers, weighed against the leader. The pick card sits out; the canon stays, as always.`; - const telemetryBlock = data.source === 'api' - ? `TELEMETRY: after the user's choice resolves, rerun this script once with - --kind --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}, - adding --chosen when a dealt challenger won and keeping - --register when the resolved round came from a steered hand. - One ping per resolved attended round. The ping is anonymous, the card kind - plus the catalog id when one won; your grounded candidates' names never - leave the machine, and the ping is skipped automatically when DO_NOT_TRACK - or IMPECCABLE_NO_TELEMETRY is set.\n` - : ''; + // The one command that follows a resolved choice. It records the choice + // (anonymous telemetry on API-dealt rolls; skipped under DO_NOT_TRACK / + // IMPECCABLE_NO_TELEMETRY) and opens the build's phase machine, whose + // first gate is the comp round on a comp-led build. Every run that skipped + // the comp round did so by treating a separate "telemetry ping" as + // bookkeeping: suppressed with >/dev/null, run after the page was written, + // or never run. So there is no separate ping; the start command is the + // ping, and it is not optional. + const nextCommand = scope === 'direction' + ? `AFTER THE CHOICE, run exactly one command and follow what it prints (do not suppress its output; do not write page code before it): + node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --direction ${key} --kind ${data.source === 'api' ? ' [--chosen ]' : ''}${register ? ` --register ${register}` : ''} + It records the choice${data.source === 'api' ? ' (anonymous: card kind plus catalog id; skipped under DO_NOT_TRACK / IMPECCABLE_NO_TELEMETRY)' : ''} and opens the build phases: on a comp-led build the comp round is the first gate (three comps, one approved) and no page code is written before it closes; on a code-led build it prints the contract step. A build without this state file is a build the finish reviewer treats as having skipped the round.\n` + : (data.source === 'api' + ? `AFTER THE CHOICE, run once: node ${relative(process.cwd(), here) || '.'}/concept-seed.mjs --kind --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''} (records the choice; the locked card's comp is the approved comp, so then: node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp ).\n` + : `AFTER THE CHOICE: the locked card's comp is the approved comp; run node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp and follow what it prints.\n`); + const telemetryBlock = nextCommand; const assignedBlock = register === null ? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`} ${promotedInstruction} @@ -734,6 +740,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined, })); } else { + // A dealt roll leaves a marker the build phase clears: context.mjs and + // detect.mjs read it and refuse to treat page work as done while a + // direction is chosen but the build never started (COMP_ROUND_OPEN). + try { + const { mkdirSync, writeFileSync: wf } = await import('node:fs'); + if (scopeIdx !== -1 && args[scopeIdx + 1] === 'direction') { + mkdirSync(resolve(process.cwd(), '.impeccable', 'build'), { recursive: true }); + wf(resolve(process.cwd(), '.impeccable', 'build', 'pending.json'), JSON.stringify({ scope: 'direction', at: new Date().toISOString() }, null, 2)); + } + } catch { /* marker is best-effort */ } // Mechanical init gate: prose alone does not keep a model from dealing // before init, and fresh repos produced exactly that skip (the model // rolled directions with no PRODUCT.md, so nothing grounded the fusion). diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index ea5cad4c0..41112429a 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1172,6 +1172,7 @@ async function cli() { appendDetectorFallback(parts, ctx); appendImageGenDirective(parts); appendBuildPathDirective(parts, ctx); + await appendCompRoundOpenDirective(parts, ctx); appendAutonomyCounterDirective(parts); appendSubagentAuthorizationDirective(parts); if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { @@ -1191,6 +1192,7 @@ async function cli() { appendDetectorFallback(parts, ctx); appendImageGenDirective(parts); appendBuildPathDirective(parts, ctx); + await appendCompRoundOpenDirective(parts, ctx); appendAutonomyCounterDirective(parts); appendSubagentAuthorizationDirective(parts); if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { @@ -1329,6 +1331,25 @@ function readBuildPathAt(root) { // selecting another workspace, cwd is the caller's app, not the target's, and // letting it rank above the repo root hands one workspace another's workflow. // It stands in only when no project resolved at all. +// A direction was dealt for a comp-led build and the phase machine never +// started, or stopped short of the hero gate: the comp round is open. Said +// here because every model in the corpus ran context.mjs unprompted, and +// the run that skipped the round did so between the roll and the first +// write; a boot that names the open round is a boot the write cannot claim +// it never saw. Reads build-phase's own helper so the two agree. +async function appendCompRoundOpenDirective(parts, ctx) { + try { + const { compRoundOpen } = await import('./build-phase.mjs'); + const roots = [...new Set([ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((r) => path.resolve(r)))]; + for (const root of roots) { + const open = compRoundOpen(root); + if (!open) continue; + parts.push(`COMP_ROUND_OPEN: ${open.reason}. On a comp-led build no page code is written before build-phase.mjs closes the comps, spec, plates, and hero gates; run \`node ${path.dirname(fileURLToPath(import.meta.url))}/build-phase.mjs status\` and follow its NEXT line. A page written past an open round is what the finish reviewer sends back.`); + return; + } + } catch { /* build-phase absent: nothing to say */ } +} + function appendBuildPathDirective(parts, ctx) { const roots = [...new Set( [ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)), diff --git a/skill/scripts/detect.mjs b/skill/scripts/detect.mjs index cbc046954..1299d4642 100644 --- a/skill/scripts/detect.mjs +++ b/skill/scripts/detect.mjs @@ -18,4 +18,13 @@ if (!detectorPath) { const { detectCli } = await import(pathToFileURL(detectorPath)); +// A comp-led build with its comp round or hero gate still open is not a page +// the detector can pass: say so after the scan (stderr, so --json stays +// parseable), on the same condition context.mjs reports at boot. +try { + const { compRoundOpen } = await import(pathToFileURL(path.join(__dirname, 'build-phase.mjs'))); + const open = compRoundOpen(process.cwd()); + if (open) process.stderr.write(`COMP_ROUND_OPEN: ${open.reason}. A detector pass is not a finish: run node ${__dirname}/build-phase.mjs status and follow its NEXT line before treating this page as built.\n`); +} catch { /* build-phase absent */ } + await detectCli(); diff --git a/skill/scripts/generate-image.mjs b/skill/scripts/generate-image.mjs index cfcb43eed..e0a4de78b 100644 --- a/skill/scripts/generate-image.mjs +++ b/skill/scripts/generate-image.mjs @@ -205,7 +205,7 @@ function parseSize(sizeStr) { const plateId = arg('plate'); let plateCtx = null; if (plateId) { - const { loadSpec, platePrompt, SPEC_PATH } = await import('./comp-spec.mjs'); + const { loadSpec, platePrompt, plateReference, SPEC_PATH } = await import('./comp-spec.mjs'); const { decodePng, encodePng } = await import('./lib/png.mjs'); const { crop, resize } = await import('./lib/raster.mjs'); const specPath = arg('spec', SPEC_PATH); @@ -216,7 +216,7 @@ if (plateId) { if (region.medium !== 'raster') { console.error(`generate-image: region ${plateId} is ${region.medium}, not a plate; set its kind to plate|image|texture in the regions file`); process.exit(1); } let comp; try { comp = decodePng(fs.readFileSync(spec.comp)); } catch (e) { console.error(`generate-image: cannot read comp ${spec.comp}: ${e.message}`); process.exit(1); } - const ref = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); + const ref = plateReference(comp, spec, region); const refPath = path.join(path.dirname(specPath), 'crops', `${region.id}.png`); fs.mkdirSync(path.dirname(refPath), { recursive: true }); fs.writeFileSync(refPath, encodePng(ref, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } })); @@ -249,7 +249,8 @@ async function scorePlate(ctx, outFile) { const min = arg('min') ? parseFloat(arg('min')) : null; const line = `PLATE-SCORE ${ctx.region.id} ${(s.overall * 100).toFixed(0)}% against the comp region (structure ${(s.structure * 100).toFixed(0)}%, color ${(s.color * 100).toFixed(0)}%, detail ${(s.detail * 100).toFixed(0)}%)`; console.log(line); - if (s.overall < 0.5) console.log(`PLATE-WARN the plate does not read as region ${ctx.region.id}; open ${outFile} beside ${ctx.refPath} and regenerate with a stricter prompt (or pass a different --ref) before building on it.`); + const bad = s.structure < 0.4 || s.overall < 0.4; + if (bad) console.log(`PLATE-WARN the plate does not read as region ${ctx.region.id} (structure ${(s.structure * 100).toFixed(0)}% must be >= 40%, overall >= 40%); open ${outFile} beside ${ctx.refPath} and regenerate with a stricter prompt before building on it. The plates gate will refuse it as it stands.`); if (min != null && s.overall < min) { console.log(`PLATE-REJECTED below --min ${(min * 100).toFixed(0)}%`); process.exit(3); } } catch (e) { console.log(`PLATE-SCORE unavailable: ${e.message}`); diff --git a/skill/scripts/lib/image-metrics.mjs b/skill/scripts/lib/image-metrics.mjs index 2100d7da4..baa5c95a1 100644 --- a/skill/scripts/lib/image-metrics.mjs +++ b/skill/scripts/lib/image-metrics.mjs @@ -76,7 +76,7 @@ export function ssim(a, b, win = 8) { } /** SSIM of `a` against `b` shifted by (dx, dy); the overlap is compared, edges dropped. */ -function ssimShifted(a, b, dx, dy, win) { +export function ssimShifted(a, b, dx, dy, win = 8) { const w = a.width - Math.abs(dx), h = a.height - Math.abs(dy); if (w < win || h < win) return 0; const sa = { width: w, height: h, data: new Float32Array(w * h) }; @@ -184,7 +184,7 @@ export function toHex(rgb) { /** * Palette match 0..1: for each dominant comp color, coverage-weighted best - * Lab match in the build's dominant set (dE 0 -> 1, dE >= 40 -> 0). + * Lab match in the build's dominant set (dE 0 -> 1, dE >= 25 -> 0). */ export function paletteMatch(compColors, buildColors) { if (!compColors.length) return 1; @@ -192,7 +192,7 @@ export function paletteMatch(compColors, buildColors) { for (const c of compColors) { let best = Infinity; for (const b of buildColors) best = Math.min(best, deltaE(c.lab, b.lab)); - s += c.coverage * Math.max(0, 1 - best / 40); wsum += c.coverage; + s += c.coverage * Math.max(0, 1 - best / 25); wsum += c.coverage; } return wsum ? s / wsum : 1; } @@ -240,11 +240,15 @@ export function detailScore(imgA, imgB, cols = 12, rows = 8) { for (let i = 0; i < a.cells.length; i++) { const ca = a.cells[i], cb = b.cells[i]; ratios[i] = ca > floor ? cb / ca : (cb > floor ? Infinity : 1); - if (ca > floor) { s += Math.min(1, cb / ca) * ca; w += ca; } + // Signed: too much energy is as wrong as too little. Noise, a tile + // shuffle, or a mosaic saturate a one-sided ratio; a real plate does not. + if (ca > floor) { s += Math.min(cb / ca, ca / cb) * ca; w += ca; } if (cb > ca * 1.8 && cb > floor * 2) { added += 1; } addedW += 1; } - return { score: w ? s / w : 1, addedFraction: addedW ? added / addedW : 0, comp: a, build: b, ratios }; + const addedFraction = addedW ? added / addedW : 0; + const raw = w ? s / w : 1; + return { score: Math.max(0, raw - 0.5 * addedFraction), rawScore: raw, addedFraction, comp: a, build: b, ratios }; } // ---- pixel diff ----------------------------------------------------------- diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs index f0b504ffb..55ff666bb 100644 --- a/tests/build-phase.test.mjs +++ b/tests/build-phase.test.mjs @@ -44,7 +44,7 @@ describe('comp-spec', () => { it('measures regions with palette, pixel box, medium, and plate path', () => { const comp = makeComp(); - const spec = measureRegions(comp, { regions: [ + const spec = measureRegions(comp, { allowUncovered: true, regions: [ { id: 'masthead', kind: 'chrome', grid: 'A0:J0' }, { id: 'art', kind: 'plate', grid: 'F1:J4', note: 'noise plate' }, ] }, 'comp.png'); @@ -59,6 +59,14 @@ describe('comp-spec', () => { assert.match(platePrompt(spec, art), /noise plate/); }); + it('refuses a regions file that leaves comp ink unnamed, unless allowUncovered', () => { + const comp = makeComp(); + // only the masthead named: the headline, plate, and list are ink no region covers + assert.throws(() => measureRegions(comp, { regions: [{ id: 'masthead', kind: 'chrome', grid: 'A0:J0' }] }, 'c.png'), /carry ink no region names/); + const spec = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'masthead', kind: 'chrome', grid: 'A0:J0' }] }, 'c.png'); + assert.ok(spec.uncoveredInkCells.length > 3); + }); + it('rejects duplicate ids and missing ids', () => { const comp = makeComp(); assert.throws(() => measureRegions(comp, { regions: [{ id: 'a', grid: 'A0:A0' }, { id: 'a', grid: 'B0:B0' }] }, 'c.png'), /duplicate/); @@ -209,7 +217,7 @@ describe('build-phase state machine (CLI)', () => { const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-hero-')); const comp = makeComp(); fs.writeFileSync(path.join(d3, 'comp.png'), encodePng(comp)); - fs.writeFileSync(path.join(d3, 'regions.json'), JSON.stringify({ regions: [ + fs.writeFileSync(path.join(d3, 'regions.json'), JSON.stringify({ allowUncovered: true, regions: [ { id: 'masthead', kind: 'chrome', grid: 'A0:J0' }, { id: 'art', kind: 'plate', grid: 'F1:J4' }, { id: 'list', kind: 'control', grid: 'A5:J9' }, diff --git a/tests/concept-seed.test.mjs b/tests/concept-seed.test.mjs index 2991d57e2..1e04ab613 100644 --- a/tests/concept-seed.test.mjs +++ b/tests/concept-seed.test.mjs @@ -798,7 +798,11 @@ describe('API roll path', () => { assert.equal(requests.some(url => url.startsWith('/api/roll?')), true, 'the CLI must hit the roll endpoint'); assert.match(result.stdout, /source: api/); assert.match(result.stdout, /letterpress print shop/); - assert.match(result.stdout, /TELEMETRY:/); + // The choice-recording command rides on build-phase start now (the + // separate TELEMETRY ping was the step every comp-round-skipping run + // suppressed); an API roll names it with the --chosen slot. + assert.match(result.stdout, /AFTER THE CHOICE, run exactly one command/); + assert.match(result.stdout, /build-phase\.mjs start --direction [\w-]+ --kind \[--chosen \]/); assert.match(result.stderr, /DISPATCHER_DESTROY_CALLED/, 'the dispatcher must be destroyed before process.exit'); } finally { server.close();