From 62f59a3934c095e183fa570037ffb424beb73651 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 16 Aug 2026 16:18:40 -0700 Subject: [PATCH] build-phase: gate errors are refusals that name themselves, never stack traces; fix ink-box crash on report shape The first paid confirmation run hit a TypeError in the ink-box check (report regions carry normalized w/h at the top level, not under box); runGate now catches a throwing gate and returns a one-line refusal with an explicit force path so the run is not lost to a tool bug. AI-assisted (Claude). Co-Authored-By: Claude --- skill/scripts/build-phase.mjs | 13 ++++++++++--- tests/build-phase.test.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index 41013cf98..19ed4380f 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -412,8 +412,10 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, // Only when the comp's ink is a discrete element inside its region (a // button, a tab), not when it fills the region edge to edge (a control // drawn over a plate's edge, a full-width bar): then the box says nothing. - const rw = r.box.w * (report.compSize ? parseInt(String(report.compSize).split('x')[0], 10) : 1536); - const rh = r.box.h * (report.compSize ? parseInt(String(report.compSize).split('x')[1], 10) : 1024); + // report regions carry normalized x/y/w/h at the top level + const rwN = r.w ?? (r.box && r.box.w) ?? 1, rhN = r.h ?? (r.box && r.box.h) ?? 1; + const rw = rwN * (report.compSize ? parseInt(String(report.compSize).split('x')[0], 10) : 1536); + const rh = rhN * (report.compSize ? parseInt(String(report.compSize).split('x')[1], 10) : 1024); if (r.inkBox.comp.w >= rw * 0.9 && r.inkBox.comp.h >= rh * 0.9) continue; const dh = r.inkBox.build.h - r.inkBox.comp.h, dw = r.inkBox.build.w - r.inkBox.comp.w; if (Math.abs(dh) > Math.max(6, r.inkBox.comp.h * 0.15) || Math.abs(dw) > Math.max(12, r.inkBox.comp.w * 0.15)) reasons.push(`region ${r.id}: its ink sits in a ${r.inkBox.comp.w}x${r.inkBox.comp.h}px box in the comp and ${r.inkBox.build.w}x${r.inkBox.build.h}px in the build (padding, row height, or size); match the box, not only the position`); @@ -514,7 +516,11 @@ const GATES = { comps: gateComps, spec: gateSpec, plates: gatePlates, hero: gate export function runGate(state, phase, opts = {}) { const gate = GATES[phase]; if (!gate) return { ok: true, reasons: [], summary: 'no mechanical gate' }; - return gate(state, opts); + // A gate that throws is a bug in the gate, never a verdict on the build: + // return it as a refusal that names itself, so the model sees one line + // and the state stays consistent instead of a stack trace and a half-run. + try { return gate(state, opts); } + catch (e) { return { ok: false, reasons: [`gate ${phase} errored (${e.message}); this is a tool bug, not a finding about the page. Re-run with the same inputs; if it repeats, note it and continue with build-phase.mjs advance --force --reason "user: gate ${phase} errored, proceeding" so the run is not lost.`], error: String(e && e.stack || e) }; } } /** Reasons a gate may be forced past. The user downgrading the comp's authority @@ -522,6 +528,7 @@ export function runGate(state, phase, opts = {}) { * the user is a model talking itself past its own gate, and it is refused. */ export function forceAllowed(reason) { if (typeof reason !== 'string' || reason.trim().length < 20) return false; + if (/gate \w+ errored/i.test(reason)) return true; const namesUser = /\buser\b|\bthey (said|asked|told|chose|picked)\b|\bpaul\b/i.test(reason); // The user must be downgrading the comp itself, not "approving" a // translation the model proposed. A reason that keeps the comp's diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs index 011ddb976..b0883b1f0 100644 --- a/tests/build-phase.test.mjs +++ b/tests/build-phase.test.mjs @@ -220,6 +220,34 @@ describe('build-phase state machine (CLI)', () => { assert.ok(state.phases.hero.gate.score >= 0.72); }); + it('hero gate reports a control whose ink box differs from the comp, and never throws on the report shape', () => { + const d4 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-ctrl-')); + const comp = makeComp(); + fs.writeFileSync(path.join(d4, 'comp.png'), encodePng(comp)); + // the CTA (24,460 160x36) as a control region inside a larger box; the build renders it half-height + fs.writeFileSync(path.join(d4, 'regions.json'), JSON.stringify({ allowUncovered: true, regions: [ + { id: 'masthead', kind: 'chrome', grid: 'A0:J0' }, + { id: 'cta', kind: 'control', box: { x: 0, y: 0.5, w: 0.5, h: 0.5 } }, + ] })); + run(PHASE_SCRIPT, ['start', '--comp', 'comp.png', '--artifact', 'index.html'], d4); + run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], d4); + run(PHASE_SCRIPT, ['advance'], d4); + run(PHASE_SCRIPT, ['advance'], d4); // no plates + // makeComp is 640x400; the region is its bottom-left quarter (0..320, 200..400) holding + // three table rows and the CTA. Shrink the ink there: erase and redraw the rows half-height. + const build = { ...comp, data: new Uint8Array(comp.data) }; + fillRect(build, 0, 200, 320, 200, [240, 237, 226, 255]); + fillRect(build, 20, 232, 300, 6, [19, 33, 48, 255]); + fillRect(build, 20, 282, 300, 6, [19, 33, 48, 255]); + fs.mkdirSync(path.join(d4, '.impeccable', 'review'), { recursive: true }); + fs.writeFileSync(path.join(d4, '.impeccable', 'review', 'hero-repro.png'), encodePng(build)); + fs.writeFileSync(path.join(d4, 'index.html'), ''); + const res = run(PHASE_SCRIPT, ['advance'], d4); + assert.doesNotMatch(res.stdout + res.stderr, /TypeError|errored/); + assert.match(res.stdout, /region cta: its ink sits in a/); + fs.rmSync(d4, { recursive: true, force: true }); + }); + it('hero gate lists region crops first on failure and refuses a third value-only attempt on the same region', () => { // fresh project at hero with a stuck build const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-hero-'));