Files
pbakaus_impeccable/skill/scripts/build-phase.mjs
T
b0fc2e8801 Add comp-diff, comp-spec, and build-phase: measured comp fidelity for the build phase
Dependency-free PNG codec, perceptual metrics (structure / color / detail /
bands), side-by-side + heatmap + per-region crops, a measured spec from the
approved comp (grid overlay, sampled palette, plate list), and a phase state
machine whose spec / plates / hero gates run the diff instead of asking the
model to remember the image.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:09:46 +05:00

296 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* build-phase: the comp-led build as a state machine on disk, so the phases
* new-work.md names are gated by scripts instead of remembered by the model.
*
* State lives at .impeccable/build/state.json. Phases, in order:
*
* spec the approved comp is measured (comp-spec.mjs wrote spec.json)
* plates every raster region in the spec has its plate on disk
* hero the first viewport is reproduced: comp-diff of hero-repro.png
* against the comp clears the gate
* sections the rest of the surface is built inside the spec's system
* motion interaction, reveals, motion
* responsive the other viewports
* review the finish reviewer ran; disposition recorded
*
* node build-phase.mjs start --comp <approved.png> [--breakpoint 1440x900]
* node build-phase.mjs status # human-readable, plus NEXT line
* node build-phase.mjs status --json
* node build-phase.mjs advance # try to close the current phase; runs its gate
* node build-phase.mjs advance --force --reason "<why>" # skip a gate; recorded, never silent
* node build-phase.mjs record hero --build .impeccable/review/hero-repro.png # run the hero gate explicitly
* node build-phase.mjs note "<text>" # append a note to the current phase
* node build-phase.mjs finish --disposition ship|fix|rebuild|recapture
*
* Gates:
* 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.
* hero -> .impeccable/review/hero-repro.png exists and comp-diff overall
* >= HERO_MIN (default 0.72) with no region `missing`. The
* score, the report path, and the attempt count are recorded.
* sections / motion / responsive -> no mechanical gate; advancing records
* the moment, and the finish reviewer reads the timeline.
*
* Exit codes: 0 ok / advanced, 2 gate failed (state unchanged, reasons
* printed), 1 usage.
*
* Nothing here needs a browser. Screenshots come from the harness; this
* script only measures them.
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { decodePng } from './lib/png.mjs';
import { crop } from './lib/raster.mjs';
import { compare, verdictFor } from './comp-diff.mjs';
import { SPEC_PATH, BUILD_DIR, loadSpec } 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 = ['spec', 'plates', 'hero', 'sections', 'motion', 'responsive', 'review'];
export const HERO_MIN = 0.72;
export const PLATE_MIN = 0.6;
export const HERO_REPRO = path.join('.impeccable', 'review', 'hero-repro.png');
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 flag = (name) => process.argv.includes(`--${name}`);
const now = () => new Date().toISOString();
export function loadState(statePath = STATE_PATH) {
if (!fs.existsSync(statePath)) return null;
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
}
export function saveState(state, statePath = STATE_PATH) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
}
export function newState({ comp, breakpoint = null }) {
return {
tool: 'build-phase',
version: 1,
startedAt: now(),
comp,
breakpoint,
phase: 'spec',
phases: Object.fromEntries(PHASES.map((p) => [p, { status: p === 'spec' ? 'open' : 'pending', openedAt: p === 'spec' ? now() : null, closedAt: null, attempts: 0, notes: [], gate: null, forced: null }])),
finish: null,
};
}
// ---- gates -----------------------------------------------------------------
export function gateSpec(state, { specPath = SPEC_PATH } = {}) {
const spec = loadSpec(specPath);
if (!spec) return { ok: false, reasons: [`no spec at ${specPath}: run comp-spec.mjs --comp ${state.comp} --grid, name the regions, then --regions regions.json`] };
if (!spec.regions || spec.regions.length < 1) return { ok: false, reasons: ['spec has no regions'] };
if (spec.comp && state.comp && path.resolve(spec.comp) !== path.resolve(state.comp)) {
return { ok: false, reasons: [`spec measures ${spec.comp}, but this build started on ${state.comp}; re-run comp-spec on the approved comp`] };
}
const plates = spec.regions.filter((r) => r.medium === 'raster').length;
return { ok: true, reasons: [], summary: `${spec.regions.length} regions, ${plates} plates` };
}
export function gatePlates(state, { specPath = SPEC_PATH } = {}) {
const spec = loadSpec(specPath);
if (!spec) return { ok: false, reasons: ['no spec'] };
const rasterRegions = spec.regions.filter((r) => r.medium === 'raster');
if (!rasterRegions.length) return { ok: true, reasons: [], summary: 'no plates owed', plates: [] };
let comp = null;
try { comp = decodePng(fs.readFileSync(spec.comp)); } catch { /* scored without the comp crop below */ }
const reasons = [], plates = [];
for (const r of rasterRegions) {
const file = r.plate;
if (!file || !fs.existsSync(file)) { reasons.push(`plate missing for ${r.id}: expected ${file || '(no path)'}; produce it from comp-spec.mjs --crop ${r.id} with generate-image.mjs --plate`); plates.push({ id: r.id, file, status: 'missing' }); continue; }
let img;
try { img = decodePng(fs.readFileSync(file)); } catch (e) { reasons.push(`plate ${file} is not a decodable PNG: ${e.message}`); plates.push({ id: r.id, file, status: 'unreadable' }); continue; }
const minW = r.px.w * 1.5;
if (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 1.5x (${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 res = compare({ comp: ref, build: img, align: 'stretch', spec: null });
score = res.whole;
if (score.overall < PLATE_MIN) reasons.push(`plate ${file} scores ${(score.overall * 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 region. Regenerate with the crop as --ref and the comp-spec plate prompt.`);
}
plates.push({ id: r.id, file, status: 'ok', size: `${img.width}x${img.height}`, score: score ? score.overall : null });
}
return { ok: reasons.length === 0, reasons, summary: `${plates.filter((p) => p.status === 'ok').length}/${rasterRegions.length} plates`, plates };
}
export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, min = HERO_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'hero') } = {}) {
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 script = path.join(HERE, 'comp-diff.mjs');
const args = [script, '--comp', state.comp, '--build', buildPath, '--out-dir', outDir, '--label', 'hero', '--json'];
const spec = loadSpec(specPath);
if (spec) args.push('--spec', specPath);
const res = spawnSync(process.execPath, args, { encoding: 'utf8' });
if (res.status !== 0 && res.status !== 3) return { ok: false, reasons: [`comp-diff failed: ${res.stderr || res.stdout}`] };
let report;
try { report = JSON.parse(res.stdout); } catch { return { ok: false, reasons: ['comp-diff produced no report'] }; }
const reasons = [];
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)}%)`);
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(', ')}`);
return {
ok: reasons.length === 0,
reasons,
summary: `hero ${(report.overall * 100).toFixed(0)}% (${report.verdict})`,
score: report.overall,
verdict: report.verdict,
report: path.join(outDir, 'report.json'),
sideBySide: report.files ? report.files.sideBySide : null,
worst: [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3).map((r) => `${r.id} ${r.verdict} ${(r.score.overall * 100).toFixed(0)}%`),
};
}
const GATES = { spec: gateSpec, plates: gatePlates, hero: gateHero };
// ---- transitions -----------------------------------------------------------
export function runGate(state, phase, opts = {}) {
const gate = GATES[phase];
if (!gate) return { ok: true, reasons: [], summary: 'no mechanical gate' };
return gate(state, opts);
}
export function advance(state, { force = false, reason = null, gateOpts = {} } = {}) {
const phase = state.phase;
const idx = PHASES.indexOf(phase);
if (idx === -1 || phase === 'review') return { ok: false, reasons: [`phase ${phase} cannot advance; use finish`] };
const p = state.phases[phase];
p.attempts += 1;
const gate = runGate(state, phase, gateOpts);
const { plates: _p, ...gateRecord } = gate;
p.gate = { ...gateRecord, at: now() };
if (!gate.ok && !force) { p.status = 'open'; return { ok: false, phase, reasons: gate.reasons, gate }; }
if (!gate.ok && force) p.forced = { at: now(), reason: reason || '(no reason given)', reasons: gate.reasons };
p.status = 'closed'; p.closedAt = now();
const next = PHASES[idx + 1];
state.phase = next;
state.phases[next].status = 'open'; state.phases[next].openedAt = now();
return { ok: true, phase, next, gate, forced: !!p.forced };
}
export function nextInstruction(state) {
switch (state.phase) {
case 'spec': return `Measure the comp: node comp-spec.mjs --comp ${state.comp} --grid, open ${path.join(BUILD_DIR, 'comp-grid.png')}, write regions.json (every illustration, photo, texture as its own plate region), run comp-spec.mjs --comp ${state.comp} --regions regions.json, then build-phase.mjs advance.`;
case 'plates': return 'Produce every plate in the spec (comp-spec.mjs --print lists them): comp-spec.mjs --crop <id>, then generate-image.mjs --plate <id> (or the harness image tool with the crop as reference and the comp-spec plate prompt). Then build-phase.mjs advance. Write no page code before this passes.';
case 'hero': return `Build only the first viewport at ${state.breakpoint || 'the comp size'} using the plates and the spec's boxes and palette; capture it into ${HERO_REPRO}; run build-phase.mjs advance. Fix the worst regions it names and re-run; do not build past the hero until it passes.`;
case 'sections': return 'Build the remaining sections inside the spec system (same corner language, rules, and palette; nothing the comp does not show). Then build-phase.mjs advance.';
case 'motion': return 'Add the signature interaction, reveals, and motion. Then build-phase.mjs advance.';
case 'responsive': return 'Build the other viewports (mobile first if the surface is mobile). Capture desktop.png and mobile.png into .impeccable/review/. Then build-phase.mjs advance.';
case 'review': return 'Spawn the finish reviewer with the state file, the hero diff report, and the captures; record its disposition with build-phase.mjs finish --disposition <word>.';
default: return '';
}
}
export function renderStatus(state) {
const lines = [`BUILD-PHASE ${state.phase.toUpperCase()} comp ${state.comp}${state.breakpoint ? ` breakpoint ${state.breakpoint}` : ''}`];
for (const p of PHASES) {
const s = state.phases[p];
let line = ` ${p.padEnd(11)} ${s.status.padEnd(8)}`;
if (s.gate && s.gate.summary) line += ` ${s.gate.summary}`;
if (s.attempts > 1) line += ` (${s.attempts} attempts)`;
if (s.forced) line += ` FORCED: ${s.forced.reason}`;
lines.push(line);
}
if (state.finish) lines.push(` finish ${state.finish.disposition} at ${state.finish.at}`);
lines.push(`NEXT ${nextInstruction(state)}`);
return lines.join('\n');
}
async function main() {
const cmd = process.argv[2];
if (!cmd || flag('help')) {
console.error('usage: build-phase.mjs start --comp <png> [--breakpoint WxH] | status [--json] | advance [--force --reason "..."] | record hero --build <png> | note "<text>" | finish --disposition <word>');
process.exit(1);
}
if (cmd === 'start') {
const comp = arg('comp');
if (!comp || !fs.existsSync(comp)) { console.error('build-phase: --comp <approved comp png> is required and must exist'); process.exit(1); }
let breakpoint = arg('breakpoint');
if (!breakpoint) { try { const i = decodePng(fs.readFileSync(comp)); breakpoint = `${i.width}x${i.height}`; } catch { /* leave null */ } }
const existing = loadState();
if (existing && !flag('reset')) {
console.log(`build-phase: state exists (phase ${existing.phase}); pass --reset to start over`);
console.log(renderStatus(existing));
return;
}
const state = newState({ comp, breakpoint });
saveState(state);
console.log(renderStatus(state));
return;
}
const state = loadState();
if (!state) { console.error(`build-phase: no state at ${STATE_PATH}; run build-phase.mjs start --comp <approved comp>`); process.exit(1); }
if (cmd === 'status') {
if (flag('json')) console.log(JSON.stringify(state, null, 2)); else console.log(renderStatus(state));
return;
}
if (cmd === 'note') {
const text = process.argv.slice(3).filter((a) => !a.startsWith('--')).join(' ');
state.phases[state.phase].notes.push({ at: now(), text });
saveState(state);
console.log(`noted on ${state.phase}`);
return;
}
if (cmd === 'record') {
const which = process.argv[3];
if (which !== 'hero') { console.error('build-phase: record hero --build <png>'); process.exit(1); }
const gate = gateHero(state, { buildPath: arg('build', HERO_REPRO), min: arg('min') ? parseFloat(arg('min')) : HERO_MIN });
state.phases.hero.attempts += 1;
state.phases.hero.gate = { ...gate, at: now() };
saveState(state);
console.log(`${gate.ok ? 'PASS' : 'FAIL'} ${gate.summary || ''}`);
for (const r of gate.reasons) console.log(` - ${r}`);
if (gate.worst) console.log(` worst: ${gate.worst.join('; ')}`);
if (gate.sideBySide) console.log(` open ${gate.sideBySide}`);
process.exit(gate.ok ? 0 : 2);
}
if (cmd === 'advance') {
const gateOpts = {};
if (arg('build')) gateOpts.buildPath = arg('build');
if (arg('min')) gateOpts.min = parseFloat(arg('min'));
const res = advance(state, { force: flag('force'), reason: arg('reason'), gateOpts });
saveState(state);
if (!res.ok) {
console.log(`GATE ${res.phase ? res.phase.toUpperCase() : ''} FAILED (state unchanged)`);
for (const r of res.reasons) console.log(` - ${r}`);
if (res.gate && res.gate.worst) console.log(` worst: ${res.gate.worst.join('; ')}`);
if (res.gate && res.gate.sideBySide) console.log(` open ${res.gate.sideBySide} and the worst region pairs before editing`);
process.exit(2);
}
console.log(`ADVANCED ${res.phase} -> ${res.next}${res.forced ? ' (FORCED; recorded)' : ''}${res.gate.summary ? ` ${res.gate.summary}` : ''}`);
console.log(`NEXT ${nextInstruction(state)}`);
return;
}
if (cmd === 'finish') {
const disposition = arg('disposition');
if (!['ship', 'fix', 'rebuild', 'recapture'].includes(disposition)) { console.error('build-phase: finish --disposition ship|fix|rebuild|recapture'); process.exit(1); }
state.finish = { disposition, at: now(), phaseAtFinish: state.phase };
if (state.phase === 'review') { state.phases.review.status = 'closed'; state.phases.review.closedAt = now(); }
saveState(state);
console.log(renderStatus(state));
return;
}
console.error(`build-phase: unknown command ${cmd}`);
process.exit(1);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
if (isMain) main();