mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Close the comp-round gap and make the hero gate teach
concept-seed's choice ping now prints the next mandatory step from the recorded build path (comp-led: build-phase.mjs start --direction <key>), because every run that skipped the comp round did so right after that ping. build-phase gains a comps phase ahead of spec (three sidecar'd comps under .impeccable/mocks/, one approved) and records the approved comp on close. The hero gate lists the worst region crops first with the fix class per verdict, and refuses a third value-only attempt on the same stuck region. Hero instruction is plates first, then the semantic layer. The finish reviewer treats a comp-led build with no closed comps phase as a material finding. AI-assisted (Claude). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude
parent
2fbfef0b43
commit
6dd15238c4
@@ -27,7 +27,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
|
||||
## Checks, in order
|
||||
|
||||
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
|
||||
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `spec`, `plates`, and `hero` phases are `closed`; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
|
||||
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
|
||||
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
|
||||
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
|
||||
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
|
||||
|
||||
@@ -102,7 +102,7 @@ Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` (
|
||||
|
||||
1. **spec.** Measure the comp: `comp-spec.mjs --comp <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 <comp> --regions <file>`. 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. <!-- rule:skill-comp-spec -->
|
||||
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 <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` 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. <!-- rule:skill-plates-before-page -->
|
||||
3. **hero.** Build only the first viewport, at the comp's own dimensions, from the spec's boxes, palette, and plates (every plate placed in the markup: an `<img>`, a background image, or an inlined data URI named for it); capture it into `.impeccable/review/hero-repro.png` at those dimensions; advance. 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 side-by-side and the worst region pairs it names, fix those regions, recapture, and advance again; the numbers rank, the crops decide. 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. <!-- rule:skill-hero-gate -->
|
||||
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 `<img>`, 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. <!-- rule:skill-hero-gate -->
|
||||
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.
|
||||
5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered.
|
||||
6. **responsive.** The other viewports. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
|
||||
|
||||
+108
-14
@@ -5,6 +5,10 @@
|
||||
*
|
||||
* State lives at .impeccable/build/state.json. Phases, in order:
|
||||
*
|
||||
* comps the comp round: three comps of the chosen direction under
|
||||
* .impeccable/mocks/ with prompt sidecars, one approved by the
|
||||
* user (sidecar "approved": true). Skipped when start names an
|
||||
* approved --comp (a surface round already locked one).
|
||||
* 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
|
||||
@@ -15,6 +19,7 @@
|
||||
* review the finish reviewer ran; disposition recorded
|
||||
*
|
||||
* node build-phase.mjs start --comp <approved.png> [--breakpoint 1440x900] [--artifact index.html]
|
||||
* node build-phase.mjs start --direction <seed key> # no comp yet: opens the comps phase first
|
||||
* 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
|
||||
@@ -24,6 +29,10 @@
|
||||
* node build-phase.mjs finish --disposition ship|fix|rebuild|recapture
|
||||
*
|
||||
* Gates:
|
||||
* comps -> >= 3 comp rasters (png/webp/jpg) directly under
|
||||
* .impeccable/mocks/ (decision/ excluded), each with a .json
|
||||
* sidecar, and exactly one sidecar carrying "approved": true;
|
||||
* 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
|
||||
@@ -47,14 +56,17 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
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';
|
||||
|
||||
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 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 HERO_REPRO = path.join('.impeccable', 'review', 'hero-repro.png');
|
||||
@@ -78,22 +90,54 @@ export function saveState(state, statePath = STATE_PATH) {
|
||||
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
export function newState({ comp, breakpoint = null, artifact = null }) {
|
||||
export function newState({ comp = null, breakpoint = null, artifact = null, direction = null }) {
|
||||
const first = comp ? 'spec' : 'comps';
|
||||
const phases = Object.fromEntries(PHASES.map((p) => [p, { status: p === first ? 'open' : 'pending', openedAt: p === first ? now() : null, closedAt: null, attempts: 0, notes: [], gate: null, forced: null }]));
|
||||
if (comp) { phases.comps.status = 'skipped'; phases.comps.notes.push({ at: now(), text: 'started with an approved comp; the comp round happened before this state (surface round or manual)' }); }
|
||||
return {
|
||||
tool: 'build-phase',
|
||||
version: 1,
|
||||
version: 2,
|
||||
startedAt: now(),
|
||||
comp,
|
||||
direction,
|
||||
breakpoint,
|
||||
artifact,
|
||||
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 }])),
|
||||
phase: first,
|
||||
phases,
|
||||
finish: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- gates -----------------------------------------------------------------
|
||||
|
||||
/** Comp rasters directly under the mocks dir, with their sidecars. */
|
||||
export function listComps(mocksDir = MOCKS_DIR) {
|
||||
if (!fs.existsSync(mocksDir)) return [];
|
||||
const out = [];
|
||||
for (const name of fs.readdirSync(mocksDir)) {
|
||||
if (!/\.(png|webp|jpe?g)$/i.test(name)) continue;
|
||||
const file = path.join(mocksDir, name);
|
||||
if (!fs.statSync(file).isFile()) continue;
|
||||
const sidecarPath = `${file}.json`;
|
||||
let sidecar = null;
|
||||
if (fs.existsSync(sidecarPath)) { try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch { sidecar = null; } }
|
||||
out.push({ file, sidecarPath, sidecar, approved: !!(sidecar && sidecar.approved === true) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function gateComps(state, { mocksDir = MOCKS_DIR } = {}) {
|
||||
const comps = listComps(mocksDir);
|
||||
const reasons = [];
|
||||
if (comps.length < 3) reasons.push(`${comps.length} comp${comps.length === 1 ? '' : 's'} under ${mocksDir}; the comp round puts three compositional options of the chosen direction in front of the user (reference/visualize.md). Generate the missing ones (harness image tool or generate-image.mjs), each with a .json sidecar holding its prompt.`);
|
||||
const noSidecar = comps.filter((c) => !c.sidecar);
|
||||
if (noSidecar.length) reasons.push(`no prompt sidecar for: ${noSidecar.map((c) => path.basename(c.file)).join(', ')} (write <file>.json with { "prompt": "..." }; generate-image.mjs does this itself)`);
|
||||
const approved = comps.filter((c) => c.approved);
|
||||
if (approved.length === 0) reasons.push('no comp is approved: put the three comps in front of the user (decision page via serve-question.mjs, or the structured question tool), then set "approved": true in the chosen comp\'s sidecar. A delegated pick is recorded the same way and disclosed.');
|
||||
if (approved.length > 1) reasons.push(`${approved.length} comps carry "approved": true; exactly one is the approved comp: ${approved.map((c) => path.basename(c.file)).join(', ')}`);
|
||||
return { ok: reasons.length === 0, reasons, summary: `${comps.length} comps, ${approved.length} approved`, approved: approved.length === 1 ? approved[0].file : null };
|
||||
}
|
||||
|
||||
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`] };
|
||||
@@ -197,6 +241,8 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH,
|
||||
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(', ')}`);
|
||||
const worstRegions = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3);
|
||||
const regionDir = path.join(outDir, 'regions');
|
||||
return {
|
||||
ok: reasons.length === 0,
|
||||
reasons,
|
||||
@@ -205,11 +251,44 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH,
|
||||
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)}%`),
|
||||
worst: worstRegions.map((r) => `${r.id} ${r.verdict} ${(r.score.overall * 100).toFixed(0)}%`),
|
||||
worstIds: worstRegions.map((r) => r.id),
|
||||
worstCrops: worstRegions.map((r) => ({ id: r.id, verdict: r.verdict, score: r.score, file: path.join(regionDir, `${r.id}.png`) })),
|
||||
regionVerdicts: Object.fromEntries(report.regions.map((r) => [r.id, r.verdict])),
|
||||
};
|
||||
}
|
||||
|
||||
const GATES = { spec: gateSpec, plates: gatePlates, hero: gateHero };
|
||||
/**
|
||||
* The hero attempt loop: after two failed advances where the same region is
|
||||
* still missing/contradicted and the artifact changed only in CSS values,
|
||||
* refuse a third of the same kind. Missing material is not a layout
|
||||
* tolerance problem; the fix is a plate, a placed plate, or a rebuilt region.
|
||||
*/
|
||||
export function heroLoopVerdict(state, gate, artifactPath) {
|
||||
const p = state.phases.hero;
|
||||
const history = p.history || [];
|
||||
const entry = { at: now(), score: gate.score ?? null, worstIds: gate.worstIds || [], regionVerdicts: gate.regionVerdicts || {}, artifactHash: hashFile(artifactPath) };
|
||||
history.push(entry);
|
||||
p.history = history.slice(-6);
|
||||
if (history.length < 3) return null;
|
||||
const last3 = history.slice(-3);
|
||||
const stuck = last3[0].worstIds[0] && last3.every((h) => h.worstIds[0] === last3[0].worstIds[0]);
|
||||
const scores = last3.map((h) => h.score ?? 0);
|
||||
const noProgress = Math.max(...scores) - Math.min(...scores) < 0.03;
|
||||
if (stuck && noProgress) {
|
||||
return `region ${last3[0].worstIds[0]} has been the worst region for three attempts and the score moved less than 3 points: value edits are not reaching it. Open ${path.join('.impeccable', 'review', 'diff', 'hero', 'regions', `${last3[0].worstIds[0]}.png`)} and rebuild that region from the comp crop (place its plate, or produce one with generate-image.mjs --plate, or re-derive its structure from the spec box), then recapture.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hashFile(file) {
|
||||
try {
|
||||
const crypto = require('node:crypto');
|
||||
return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex').slice(0, 12);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
const GATES = { comps: gateComps, spec: gateSpec, plates: gatePlates, hero: gateHero };
|
||||
|
||||
// ---- transitions -----------------------------------------------------------
|
||||
|
||||
@@ -239,9 +318,17 @@ export function advance(state, { force = false, reason = null, gateOpts = {} } =
|
||||
p.status = 'open';
|
||||
return { ok: false, phase, reasons: [...gate.reasons, `--force refused: "${reason || ''}" does not quote the user downgrading the comp. A single-file deliverable, a missing tool, or difficulty is not a reason; embed the plate as a data URI, produce it with the harness image tool, or ask the user.`], gate };
|
||||
}
|
||||
if (phase === 'hero' && (gate.score != null)) {
|
||||
const stuck = heroLoopVerdict(state, gate, gateOpts.artifact || state.artifact || 'index.html');
|
||||
if (stuck && !gate.ok) gate.reasons = [stuck, ...gate.reasons];
|
||||
}
|
||||
if (!gate.ok && !force) { p.status = 'open'; return { ok: false, phase, reasons: gate.reasons, gate }; }
|
||||
if (!gate.ok && force) p.forced = { at: now(), reason, reasons: gate.reasons };
|
||||
p.status = 'closed'; p.closedAt = now();
|
||||
if (phase === 'comps' && gate.approved) {
|
||||
state.comp = gate.approved;
|
||||
if (!state.breakpoint) { try { const i = decodePng(fs.readFileSync(gate.approved)); state.breakpoint = `${i.width}x${i.height}`; } catch { /* non-png comp: breakpoint stays unset */ } }
|
||||
}
|
||||
const next = PHASES[idx + 1];
|
||||
state.phase = next;
|
||||
state.phases[next].status = 'open'; state.phases[next].openedAt = now();
|
||||
@@ -250,9 +337,10 @@ export function advance(state, { force = false, reason = null, gateOpts = {} } =
|
||||
|
||||
export function nextInstruction(state) {
|
||||
switch (state.phase) {
|
||||
case 'comps': return `Comp round for the chosen direction${state.direction ? ` (seed ${state.direction})` : ''}: read reference/visualize.md, generate three compositional comps of the requested surface at its own viewport into ${MOCKS_DIR}/ (each with a prompt sidecar), put them in front of the user, and set "approved": true in the chosen comp's sidecar. Then build-phase.mjs advance. No page code before this closes.`;
|
||||
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 'hero': return `Build only the first viewport at ${state.breakpoint || 'the comp size'}, plates first: place every plate at its spec box (comp-spec.mjs --print lists boxes as percentages of the viewport) with object-fit: cover before writing a line of text or a control, capture into ${HERO_REPRO}, and advance once so the gate reads the material; then lay the semantic layer (text, controls, rules) over the plates from the spec's palette and boxes, capture, advance. When it fails, open the region crops it lists first, in order, then fix; 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.';
|
||||
@@ -262,7 +350,7 @@ export function nextInstruction(state) {
|
||||
}
|
||||
|
||||
export function renderStatus(state) {
|
||||
const lines = [`BUILD-PHASE ${state.phase.toUpperCase()} comp ${state.comp}${state.breakpoint ? ` breakpoint ${state.breakpoint}` : ''}`];
|
||||
const lines = [`BUILD-PHASE ${state.phase.toUpperCase()} comp ${state.comp || '(pending comp round)'}${state.direction ? ` direction ${state.direction}` : ''}${state.breakpoint ? ` breakpoint ${state.breakpoint}` : ''}`];
|
||||
for (const p of PHASES) {
|
||||
const s = state.phases[p];
|
||||
let line = ` ${p.padEnd(11)} ${s.status.padEnd(8)}`;
|
||||
@@ -284,16 +372,18 @@ async function main() {
|
||||
}
|
||||
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); }
|
||||
const direction = arg('direction');
|
||||
if (!comp && !direction) { console.error('build-phase: start needs --comp <approved comp png> (comp already approved) or --direction <seed key> (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); }
|
||||
let breakpoint = arg('breakpoint');
|
||||
if (!breakpoint) { try { const i = decodePng(fs.readFileSync(comp)); breakpoint = `${i.width}x${i.height}`; } catch { /* leave null */ } }
|
||||
if (!breakpoint && comp) { 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, artifact: arg('artifact') });
|
||||
const state = newState({ comp, breakpoint, artifact: arg('artifact'), direction });
|
||||
saveState(state);
|
||||
console.log(renderStatus(state));
|
||||
return;
|
||||
@@ -333,9 +423,13 @@ async function main() {
|
||||
saveState(state);
|
||||
if (!res.ok) {
|
||||
console.log(`GATE ${res.phase ? res.phase.toUpperCase() : ''} FAILED (state unchanged)`);
|
||||
if (res.gate && res.gate.worstCrops && res.gate.worstCrops.length) {
|
||||
console.log(' LOOK FIRST, in this order, before editing anything (comp on the left, your build on the right):');
|
||||
for (const c of res.gate.worstCrops) console.log(` ${c.file} ${c.id}: ${c.verdict} ${(c.score.overall * 100).toFixed(0)}% (structure ${(c.score.structure * 100).toFixed(0)}%, color ${(c.score.color * 100).toFixed(0)}%, detail ${(c.score.detail * 100).toFixed(0)}%)`);
|
||||
console.log(' A region scored missing needs its material (a plate placed, or produced), not a value change; contradicted needs its structure re-derived from the spec box; drift is where padding and size edits belong.');
|
||||
}
|
||||
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`);
|
||||
if (res.gate && res.gate.sideBySide) console.log(` then ${res.gate.sideBySide} for the whole viewport`);
|
||||
process.exit(2);
|
||||
}
|
||||
console.log(`ADVANCED ${res.phase} -> ${res.next}${res.forced ? ' (FORCED; recorded)' : ''}${res.gate.summary ? ` ${res.gate.summary}` : ''}`);
|
||||
|
||||
@@ -90,7 +90,8 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
approvedPoolRevision,
|
||||
@@ -666,6 +667,36 @@ ${restated}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the model must do next, once a direction (or surface structure) is
|
||||
* chosen. Read from the same config the boot directive reads:
|
||||
* `.impeccable/config.local.json` over `.impeccable/config.json`,
|
||||
* `buildPath` comp|code; with neither, comp-led whenever image generation
|
||||
* exists (an OpenAI key here; a harness-native image tool is invisible to
|
||||
* this script, so the text names it too), code-led otherwise.
|
||||
*/
|
||||
export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = process.env } = {}) {
|
||||
let buildPath = null;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(resolve(cwd, '.impeccable', name), 'utf8'));
|
||||
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') buildPath = raw.buildPath;
|
||||
} catch { /* absent */ }
|
||||
}
|
||||
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
||||
const scripts = relative(cwd, scriptsDir) || '.';
|
||||
const imageGen = !!env.OPENAI_API_KEY;
|
||||
const seed = key ? ` --direction ${key}` : '';
|
||||
if (buildPath === 'code') {
|
||||
return `NEXT (code-led, from .impeccable config): write the direction contract, then build; no comp round. Load reference/new-work.md section 5 and 6.\n`;
|
||||
}
|
||||
const why = buildPath === 'comp' ? 'from .impeccable config' : imageGen ? 'default: image generation is available' : 'default: comp-led unless no image tool exists; if your harness truly has none and there is no OpenAI key, this is code-led and you say so in one line';
|
||||
if (scope === 'surface') {
|
||||
return `NEXT (comp-led, ${why}): the locked card's comp is the approved comp. Run: node ${scripts}/build-phase.mjs start --comp <that comp> and follow its NEXT lines. Do not write page code before build-phase.mjs advance has closed the spec, plates, and hero gates.\n`;
|
||||
}
|
||||
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
@@ -692,6 +723,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
|
||||
});
|
||||
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
|
||||
// The choice is resolved; this is the last script output the model
|
||||
// reads before it decides what to do next, and every run that skipped
|
||||
// the comp round did so right here: prose 20 KB into new-work.md lost
|
||||
// to "direction locked, building now". So the ping prints the next
|
||||
// mandatory step from the recorded build path, and the phase machine
|
||||
// takes it from there.
|
||||
process.stdout.write(nextStepAfterChoice({
|
||||
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
||||
}));
|
||||
} else {
|
||||
// Mechanical init gate: prose alone does not keep a model from dealing
|
||||
// before init, and fresh repos produced exactly that skip (the model
|
||||
|
||||
@@ -66,6 +66,57 @@ describe('comp-spec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('build-phase comps phase (start --direction)', () => {
|
||||
let dir;
|
||||
before(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-comps-'));
|
||||
fs.mkdirSync(path.join(dir, '.impeccable', 'mocks', 'decision'), { recursive: true });
|
||||
});
|
||||
after(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
|
||||
|
||||
it('opens at comps, refuses with fewer than three sidecar\'d comps or no approval, then records the approved comp', () => {
|
||||
let res = run(PHASE_SCRIPT, ['start', '--direction', 'abcd1234'], dir);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(res.stdout, /BUILD-PHASE COMPS/);
|
||||
assert.match(res.stdout, /direction abcd1234/);
|
||||
assert.match(res.stdout, /NEXT Comp round/);
|
||||
res = run(PHASE_SCRIPT, ['advance'], dir);
|
||||
assert.equal(res.status, 2);
|
||||
assert.match(res.stdout, /0 comps under/);
|
||||
// three comps, one without sidecar, none approved
|
||||
const comp = makeComp();
|
||||
for (const n of ['a', 'b', 'c']) fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', `comp-${n}.png`), encodePng(comp));
|
||||
// a decision-round comp must not count
|
||||
fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'decision', 'card.png'), encodePng(comp));
|
||||
fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-a.png.json'), JSON.stringify({ prompt: 'a' }));
|
||||
fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-b.png.json'), JSON.stringify({ prompt: 'b' }));
|
||||
res = run(PHASE_SCRIPT, ['advance'], dir);
|
||||
assert.equal(res.status, 2);
|
||||
assert.match(res.stdout, /no prompt sidecar for: comp-c.png/);
|
||||
assert.match(res.stdout, /no comp is approved/);
|
||||
fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-c.png.json'), JSON.stringify({ prompt: 'c' }));
|
||||
fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-b.png.json'), JSON.stringify({ prompt: 'b', approved: true }));
|
||||
res = run(PHASE_SCRIPT, ['advance'], dir);
|
||||
assert.equal(res.status, 0, res.stdout);
|
||||
assert.match(res.stdout, /ADVANCED comps -> spec/);
|
||||
const state = JSON.parse(fs.readFileSync(path.join(dir, '.impeccable', 'build', 'state.json'), 'utf8'));
|
||||
assert.equal(state.comp, path.join('.impeccable', 'mocks', 'comp-b.png'));
|
||||
assert.equal(state.breakpoint, '640x400');
|
||||
assert.equal(state.phases.comps.status, 'closed');
|
||||
});
|
||||
|
||||
it('start --comp skips the comps phase and records why', () => {
|
||||
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-comps2-'));
|
||||
fs.writeFileSync(path.join(d2, 'comp.png'), encodePng(makeComp()));
|
||||
const res = run(PHASE_SCRIPT, ['start', '--comp', 'comp.png'], d2);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const state = JSON.parse(fs.readFileSync(path.join(d2, '.impeccable', 'build', 'state.json'), 'utf8'));
|
||||
assert.equal(state.phase, 'spec');
|
||||
assert.equal(state.phases.comps.status, 'skipped');
|
||||
fs.rmSync(d2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('build-phase state machine (CLI)', () => {
|
||||
let dir;
|
||||
before(() => {
|
||||
@@ -153,6 +204,38 @@ describe('build-phase state machine (CLI)', () => {
|
||||
assert.ok(state.phases.hero.gate.score >= 0.72);
|
||||
});
|
||||
|
||||
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-'));
|
||||
const comp = makeComp();
|
||||
fs.writeFileSync(path.join(d3, 'comp.png'), encodePng(comp));
|
||||
fs.writeFileSync(path.join(d3, 'regions.json'), JSON.stringify({ regions: [
|
||||
{ id: 'masthead', kind: 'chrome', grid: 'A0:J0' },
|
||||
{ id: 'art', kind: 'plate', grid: 'F1:J4' },
|
||||
{ id: 'list', kind: 'control', grid: 'A5:J9' },
|
||||
] }));
|
||||
run(PHASE_SCRIPT, ['start', '--comp', 'comp.png', '--artifact', 'index.html'], d3);
|
||||
run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], d3);
|
||||
run(PHASE_SCRIPT, ['advance'], d3);
|
||||
run(SPEC_SCRIPT, ['--crop', 'art', '--scale', '2', '--out', 'assets/plates/art.png'], d3);
|
||||
run(PHASE_SCRIPT, ['advance'], d3);
|
||||
fs.writeFileSync(path.join(d3, 'index.html'), '<img src="assets/plates/art.png"><style>.a{padding:1px}</style>');
|
||||
const flat = createImage(comp.width, comp.height, [240, 237, 226, 255]);
|
||||
fillRect(flat, 0, 0, comp.width, 40, [19, 33, 48, 255]);
|
||||
fs.mkdirSync(path.join(d3, '.impeccable', 'review'), { recursive: true });
|
||||
fs.writeFileSync(path.join(d3, '.impeccable', 'review', 'hero-repro.png'), encodePng(flat));
|
||||
let res;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fs.writeFileSync(path.join(d3, 'index.html'), `<img src="assets/plates/art.png"><style>.a{padding:${i + 1}px}</style>`);
|
||||
res = run(PHASE_SCRIPT, ['advance'], d3);
|
||||
assert.equal(res.status, 2);
|
||||
}
|
||||
assert.match(res.stdout, /LOOK FIRST/);
|
||||
assert.match(res.stdout, /regions\/art\.png/);
|
||||
assert.match(res.stdout, /three attempts/);
|
||||
fs.rmSync(d3, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('later phases advance without a gate; force is recorded; finish records the disposition', () => {
|
||||
for (const from of ['sections', 'motion']) {
|
||||
const res = run(PHASE_SCRIPT, ['advance'], dir);
|
||||
|
||||
Reference in New Issue
Block a user