pixel-perfect-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLONE-ALOYOGA-HEADER.md +76 -0
- package/CLONE-ANY-HEADER.md +106 -0
- package/CLONE-ANY-SITE.md +120 -0
- package/DEVELOP.md +144 -0
- package/LAUNCH-PROMPT.md +66 -0
- package/LEARNINGS.md +398 -0
- package/LICENSE +21 -0
- package/PLAYBOOK.md +260 -0
- package/README.md +219 -0
- package/WORKFLOW.md +257 -0
- package/bin/ppk +4 -0
- package/harness/adopt.js +52 -0
- package/harness/agent-setup.js +55 -0
- package/harness/behavior-selftest.js +243 -0
- package/harness/benchmarks/README.md +34 -0
- package/harness/benchmarks/battery.js +86 -0
- package/harness/benchmarks/detection-power.js +75 -0
- package/harness/capture-build-selftest.js +134 -0
- package/harness/capture-build.js +323 -0
- package/harness/doctor-selftest.js +58 -0
- package/harness/doctor.js +85 -0
- package/harness/fixtures/01-backdrop-color.js +51 -0
- package/harness/fixtures/02-line-strut.js +53 -0
- package/harness/fixtures/03-compat-mode.js +44 -0
- package/harness/fixtures/README.md +31 -0
- package/harness/human-qa-selftest.js +201 -0
- package/harness/human-qa.js +406 -0
- package/harness/merge-snapshot-selftest.js +56 -0
- package/harness/new-target.js +101 -0
- package/harness/regression.js +50 -0
- package/harness/score.js +58 -0
- package/harness/serve.js +149 -0
- package/harness/setup-selftest.js +93 -0
- package/harness/setup.js +158 -0
- package/harness/tunnel-selftest.js +76 -0
- package/harness/tunnel.js +241 -0
- package/harness/workflow-selftest.js +211 -0
- package/harness/workflow.js +739 -0
- package/package.json +40 -0
- package/skill/ditto-finish/SKILL.md +52 -0
- package/skill/pixel-perfect-clone/SKILL.md +49 -0
- package/tools/RUNBOOK.md +228 -0
- package/tools/behavior-capture.js +307 -0
- package/tools/behavior-worksheet.js +82 -0
- package/tools/browser-capture.js +240 -0
- package/tools/cli-selftest.js +136 -0
- package/tools/extract-fonts.js +133 -0
- package/tools/extract-icons.js +255 -0
- package/tools/merge-snapshot.js +56 -0
- package/tools/pixel-diff.js +845 -0
- package/tools/reassemble.js +63 -0
- package/tools/selftest.js +67 -0
- package/tools/sink.js +80 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* workflow.js — the `ppk` enforced-workflow state machine (the kit's `gjc`).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* The kit already had the *verification primitive* (pixel-diff.js exits 0 or 1) and
|
|
7
|
+
* the *method* (PLAYBOOK phases 0–7). What it did NOT have was ENFORCEMENT: nothing
|
|
8
|
+
* stopped an agent from skipping Phase 5b coverage, hand-waving a strict delta, or
|
|
9
|
+
* declaring "pixel-perfect" without ever running the gate. The PLAYBOOK was prose the
|
|
10
|
+
* agent was *asked* to follow.
|
|
11
|
+
*
|
|
12
|
+
* This turns the documented phases into a hard-gated state machine — the same shape as
|
|
13
|
+
* gajae-code's `gjc` (deep-interview → ralplan → ultragoal), reimplemented natively with
|
|
14
|
+
* zero dependencies. Each phase has an OBJECTIVE gate: a check that must exit 0 before the
|
|
15
|
+
* phase can be marked done, and phases must complete IN ORDER. A phase you can't machine-
|
|
16
|
+
* check (e.g. "assets are real, not hand-drawn") is recorded as an ATTESTATION with
|
|
17
|
+
* evidence and flagged as such — honest about what's proven vs. asserted.
|
|
18
|
+
*
|
|
19
|
+
* Every advance appends a receipt to targets/<name>/workflow.jsonl (ts, phase, runId,
|
|
20
|
+
* sha256 of the artifact, gate result, evidence) — a durable audit trail, like gjc's
|
|
21
|
+
* index.jsonl. State lives in targets/<name>/workflow.json.
|
|
22
|
+
*
|
|
23
|
+
* The rule the whole kit is built on holds here too:
|
|
24
|
+
* A phase is done because its gate exited 0 — never because prose says so.
|
|
25
|
+
*
|
|
26
|
+
* USAGE
|
|
27
|
+
* node harness/workflow.js init <name> [url] [width] # seed state (new-target.js calls this)
|
|
28
|
+
* node harness/workflow.js status <name> # phase table + the next required action
|
|
29
|
+
* node harness/workflow.js gate <name> <phase> # run ONE gate read-only (exit 0/1) — no state change
|
|
30
|
+
* node harness/workflow.js advance <name> <phase> [--evidence "..."] [--force]
|
|
31
|
+
* node harness/workflow.js ledger <name> # print the audit trail
|
|
32
|
+
*
|
|
33
|
+
* `advance` refuses if (a) an earlier phase isn't done, or (b) the gate fails — unless
|
|
34
|
+
* --force is passed, which records forced:true in the receipt (an escape hatch that is
|
|
35
|
+
* itself auditable, like gjc's force override).
|
|
36
|
+
*/
|
|
37
|
+
"use strict";
|
|
38
|
+
|
|
39
|
+
const fs = require("fs");
|
|
40
|
+
const path = require("path");
|
|
41
|
+
const crypto = require("crypto");
|
|
42
|
+
const { diffSnapshots } = require("../tools/pixel-diff.js");
|
|
43
|
+
|
|
44
|
+
// PKG = where the kit is installed (its own tools/docs/harness live here, read-only when
|
|
45
|
+
// installed globally). WORK = the user's current directory, where their clone `targets/`
|
|
46
|
+
// are created. In the kit's own repo the two are the same, so the DEVELOP meta-loop is
|
|
47
|
+
// unchanged; installed globally, PKG is node_modules/... and WORK is the user's project.
|
|
48
|
+
const PKG = path.resolve(__dirname, "..");
|
|
49
|
+
const WORK = process.cwd();
|
|
50
|
+
// How to spell this tool in printed guidance so the hint is RUNNABLE in the invoking context:
|
|
51
|
+
// `ppk` when launched via the installed bin (or a ppk delegate), `node harness/workflow.js`
|
|
52
|
+
// when someone in the repo ran this script directly.
|
|
53
|
+
const VIA_PPK = process.env.PPK_ENTRY === "1" || /(^|\/)ppk$/.test(process.argv[1] || "");
|
|
54
|
+
const CMD = VIA_PPK ? "ppk" : "node harness/workflow.js";
|
|
55
|
+
const targetDir = (name) => path.join(WORK, "targets", name);
|
|
56
|
+
const statePath = (name) => path.join(targetDir(name), "workflow.json");
|
|
57
|
+
const ledgerPath = (name) => path.join(targetDir(name), "workflow.jsonl");
|
|
58
|
+
|
|
59
|
+
// ── small helpers ────────────────────────────────────────────────────────────
|
|
60
|
+
const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
|
61
|
+
const exists = (p) => fs.existsSync(p);
|
|
62
|
+
// Hash a file's bytes, or — for a directory — every file's relative name AND contents in
|
|
63
|
+
// sorted order. Names alone would let a same-named swap (fake woff2 over the validated one)
|
|
64
|
+
// keep the receipt hash identical, so the ledger wouldn't actually pin what was verified.
|
|
65
|
+
function sha256OfFile(p) {
|
|
66
|
+
if (!exists(p)) return null;
|
|
67
|
+
const h = crypto.createHash("sha256");
|
|
68
|
+
if (fs.statSync(p).isDirectory()) {
|
|
69
|
+
const walk = (d) => {
|
|
70
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
71
|
+
const fp = path.join(d, e.name);
|
|
72
|
+
if (e.isDirectory()) walk(fp);
|
|
73
|
+
else { h.update(path.relative(p, fp)); h.update(fs.readFileSync(fp)); }
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
walk(p);
|
|
77
|
+
} else {
|
|
78
|
+
h.update(fs.readFileSync(p));
|
|
79
|
+
}
|
|
80
|
+
return h.digest("hex").slice(0, 16);
|
|
81
|
+
}
|
|
82
|
+
const runId = () => crypto.randomBytes(5).toString("hex");
|
|
83
|
+
|
|
84
|
+
// A gate returns { ok, reason, artifact? }. `ok:true` means the phase may be marked done.
|
|
85
|
+
// `artifact` (a file path) is hashed into the receipt as evidence of WHAT was verified.
|
|
86
|
+
|
|
87
|
+
// ── behavior gate — compares behaviors-live.json vs behaviors-clone.json ────────────────
|
|
88
|
+
// Ported from lovable_dupe_html/CLONE_PLAYBOOK.md §8/§8a: static candidates + a dynamic
|
|
89
|
+
// differential pass on live are AUTHORITATIVE for what's a real behavior; reproduction in
|
|
90
|
+
// clone/fixes.js is judged by MEASURED values, never presence alone (a marquee that exists
|
|
91
|
+
// but runs at 2x speed is not "reproduced").
|
|
92
|
+
//
|
|
93
|
+
// TOLERANCES (documented here, and in the failure messages, same as pixel-diff's 0.5px):
|
|
94
|
+
// - pxPerSec (marquee/scroll speed): ±15% relative. Rationale: two independent 1-second
|
|
95
|
+
// samples of a CSS transition/rAF-driven animation carry timer/paint-scheduling jitter
|
|
96
|
+
// (observed ~3-8% on a quiet machine); 15% comfortably absorbs that noise while a
|
|
97
|
+
// genuinely wrong speed (a common miss is exactly 2x or 0.5x, from copying the wrong
|
|
98
|
+
// keyframe duration or belt width) still fails by a wide margin.
|
|
99
|
+
// - durationMs / settle timings: ±25% relative, floor 150ms absolute. Rationale: wall-clock
|
|
100
|
+
// durations for reveals/typewriters are measured across a network+paint round-trip on
|
|
101
|
+
// BOTH captures, so they carry more jitter than a same-machine transform sample; a floor
|
|
102
|
+
// avoids flagging noise on very short (<600ms) transitions where 25% would be under a
|
|
103
|
+
// frame's worth of time.
|
|
104
|
+
// - opacity: ±0.05 absolute (a fully-revealed 1.0 vs a stuck 0.92 is a real miss; 0.05
|
|
105
|
+
// absorbs float rounding across engines).
|
|
106
|
+
// - transform / filter / trigger / kind: exact string match. These aren't measurements
|
|
107
|
+
// with sampling noise — a live end-state of `translateX(-2125px)` reproduced as
|
|
108
|
+
// `translateX(-1000px)` is simply wrong, and `trigger` describes the reproduction
|
|
109
|
+
// TECHNIQUE (scroll vs hover vs load), which the playbook says must match, not just land
|
|
110
|
+
// on the right pixels by accident.
|
|
111
|
+
const BEHAVIOR_TOL = { pxPerSecRelative: 0.15, durationRelative: 0.25, durationFloorMs: 150, opacityAbs: 0.05, transformTranslatePx: 0.5, transformLinearAbs: 0.001 };
|
|
112
|
+
|
|
113
|
+
function relWithin(live, clone, relTol, floor) {
|
|
114
|
+
if (typeof live !== "number" || typeof clone !== "number" || !isFinite(live) || !isFinite(clone)) return live === clone;
|
|
115
|
+
const tol = Math.max(Math.abs(live) * relTol, floor || 0);
|
|
116
|
+
return Math.abs(live - clone) <= tol;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// getComputedStyle normalizes `transform` to none|matrix(...)|matrix3d(...), and the matrix
|
|
120
|
+
// TRANSLATION components inherit layout subpixel rounding — exact string equality would flake
|
|
121
|
+
// on jitter the pixel gate itself tolerates. Compare matrices numerically: 0.5px on the
|
|
122
|
+
// translation part (mirroring --visual's tolerance), 0.001 absolute on the linear part
|
|
123
|
+
// (scale/rotate/skew aren't layout-rounded). Anything non-matrix compares exact.
|
|
124
|
+
function transformsMatch(a, b) {
|
|
125
|
+
if (a === b) return true;
|
|
126
|
+
const pa = /^matrix(3d)?\(([^)]+)\)$/.exec(a || ""), pb = /^matrix(3d)?\(([^)]+)\)$/.exec(b || "");
|
|
127
|
+
if (!pa || !pb || pa[1] !== pb[1]) return false;
|
|
128
|
+
const na = pa[2].split(",").map(Number), nb = pb[2].split(",").map(Number);
|
|
129
|
+
if (na.length !== nb.length || na.some(isNaN) || nb.some(isNaN)) return false;
|
|
130
|
+
const isTranslate = (i) => (pa[1] ? i >= 12 && i <= 14 : i >= 4);
|
|
131
|
+
return na.every((v, i) => Math.abs(v - nb[i]) <= (isTranslate(i) ? BEHAVIOR_TOL.transformTranslatePx : BEHAVIOR_TOL.transformLinearAbs));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Compare one behavior's `measured` block. Returns { ok, detail } — detail names the exact
|
|
135
|
+
// field(s) that missed and by how much, so a failure is actionable, not "values differ".
|
|
136
|
+
function compareMeasured(key, kind, liveM, cloneM) {
|
|
137
|
+
const misses = [];
|
|
138
|
+
const num = (v) => (typeof v === "number" ? v : NaN);
|
|
139
|
+
// marquee/scroll speed
|
|
140
|
+
if (liveM && typeof liveM.pxPerSec === "number") {
|
|
141
|
+
if (!cloneM || typeof cloneM.pxPerSec !== "number") misses.push(`pxPerSec: live=${liveM.pxPerSec} clone=missing`);
|
|
142
|
+
else if (!relWithin(liveM.pxPerSec, cloneM.pxPerSec, BEHAVIOR_TOL.pxPerSecRelative))
|
|
143
|
+
misses.push(`pxPerSec: live=${liveM.pxPerSec} clone=${cloneM.pxPerSec} (outside ±${BEHAVIOR_TOL.pxPerSecRelative * 100}%)`);
|
|
144
|
+
}
|
|
145
|
+
// duration-style fields (durationMs, sampledMs) — informational only if live lacks them
|
|
146
|
+
for (const f of ["durationMs"]) {
|
|
147
|
+
if (liveM && typeof liveM[f] === "number") {
|
|
148
|
+
if (!cloneM || typeof cloneM[f] !== "number") misses.push(`${f}: live=${liveM[f]} clone=missing`);
|
|
149
|
+
else if (!relWithin(liveM[f], cloneM[f], BEHAVIOR_TOL.durationRelative, BEHAVIOR_TOL.durationFloorMs))
|
|
150
|
+
misses.push(`${f}: live=${liveM[f]} clone=${cloneM[f]} (outside ±${BEHAVIOR_TOL.durationRelative * 100}%/${BEHAVIOR_TOL.durationFloorMs}ms floor)`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// end-state opacity/transform/filter (from `after`, or the bare measured block for mutation-style).
|
|
154
|
+
// SKIPPED for kind "observed-mutation": an interval rotation's snapshot is whatever frame it
|
|
155
|
+
// happened to be on — end-state floats from two independent captures of a continuously-mutating
|
|
156
|
+
// element are not comparable; for those, key presence + trigger match ARE the contract (the
|
|
157
|
+
// clone rotates too), and anything stronger is fiction dressed as a measurement.
|
|
158
|
+
const frameNondeterministic = kind === "observed-mutation";
|
|
159
|
+
const liveEnd = !frameNondeterministic && liveM && (liveM.after || liveM);
|
|
160
|
+
const cloneEnd = cloneM && (cloneM.after || cloneM);
|
|
161
|
+
if (liveEnd && typeof liveEnd.opacity === "number") {
|
|
162
|
+
if (!cloneEnd || typeof cloneEnd.opacity !== "number") misses.push(`opacity: live=${liveEnd.opacity} clone=missing`);
|
|
163
|
+
else if (Math.abs(liveEnd.opacity - cloneEnd.opacity) > BEHAVIOR_TOL.opacityAbs)
|
|
164
|
+
misses.push(`opacity: live=${liveEnd.opacity} clone=${cloneEnd.opacity} (outside ±${BEHAVIOR_TOL.opacityAbs})`);
|
|
165
|
+
}
|
|
166
|
+
if (liveEnd && liveEnd.transform && liveEnd.transform !== "none") {
|
|
167
|
+
if (!cloneEnd || !transformsMatch(liveEnd.transform, cloneEnd.transform))
|
|
168
|
+
misses.push(`transform: live="${liveEnd.transform}" clone="${cloneEnd ? cloneEnd.transform : "missing"}" (matrix compare: ±${BEHAVIOR_TOL.transformTranslatePx}px translation)`);
|
|
169
|
+
}
|
|
170
|
+
if (liveEnd && liveEnd.filter && liveEnd.filter !== "none") {
|
|
171
|
+
if (!cloneEnd || cloneEnd.filter !== liveEnd.filter)
|
|
172
|
+
misses.push(`filter: live="${liveEnd.filter}" clone="${cloneEnd ? cloneEnd.filter : "missing"}"`);
|
|
173
|
+
}
|
|
174
|
+
// hover-mount / reveal: live observed a real change — clone must too (a frozen start state
|
|
175
|
+
// reproduces nothing, even if the numbers above happen to be absent on both sides)
|
|
176
|
+
if (liveM && liveM.changed === true && !(cloneM && cloneM.changed === true)) misses.push(`changed: live=true clone=${cloneM ? cloneM.changed : "missing"} (clone never mounted/toggled the content)`);
|
|
177
|
+
void num;
|
|
178
|
+
return { ok: misses.length === 0, detail: misses.join("; ") };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function behaviorGate(name) {
|
|
182
|
+
const dir = targetDir(name);
|
|
183
|
+
const livePath = path.join(dir, "behaviors-live.json");
|
|
184
|
+
const clonePath = path.join(dir, "behaviors-clone.json");
|
|
185
|
+
if (!exists(livePath))
|
|
186
|
+
return { ok: false, reason: `targets/${name}/behaviors-live.json missing — run the discovery pass on the LIVE site with tools/behavior-capture.js (pxBehaviorSend or pxBehaviorStash), see tools/RUNBOOK.md "Behavior discovery"` };
|
|
187
|
+
let live; try { live = readJson(livePath); } catch (e) { return { ok: false, reason: `behaviors-live.json is not valid JSON: ${e.message}` }; }
|
|
188
|
+
// "discovery ran" must be EVIDENCED, not inferred from an empty behaviors object (WORKFLOW.md:
|
|
189
|
+
// an absent/empty inventory must be an explicit gate result, never a free pass). The evidence
|
|
190
|
+
// is the discovery pass's own metadata — without it we cannot tell "nothing dynamic here" from
|
|
191
|
+
// "the script never fired".
|
|
192
|
+
const d = live.discovery;
|
|
193
|
+
if (!d || typeof d.elementsScanned !== "number" || !d.elementsScanned || !d.scrollSweep || typeof d.observeMs !== "number")
|
|
194
|
+
return { ok: false, reason: `behaviors-live.json has no discovery pass metadata (elementsScanned/scrollSweep/observeMs) — this looks like a paint-over, not a real discovery run. Re-capture with pxBehaviorDiscover()/pxBehaviorSend() from tools/behavior-capture.js.` };
|
|
195
|
+
if (!live.behaviors || typeof live.behaviors !== "object")
|
|
196
|
+
return { ok: false, reason: `behaviors-live.json missing a "behaviors" object (even an empty {} is required to prove discovery ran)` };
|
|
197
|
+
|
|
198
|
+
const liveKeys = Object.keys(live.behaviors);
|
|
199
|
+
// Declared-but-unfired rows (schema-gated: absent on pre-worksheet inventories). In an
|
|
200
|
+
// environment-inverted run (no-js / bot-gated choreography) NOTHING fires live, yet the
|
|
201
|
+
// markers still declare what is SUPPOSED to move — each declared row needs a disposition.
|
|
202
|
+
const declaredKeys = live.declared && typeof live.declared === "object" ? Object.keys(live.declared) : [];
|
|
203
|
+
if (!liveKeys.length && !declaredKeys.length) {
|
|
204
|
+
// Legitimate pass: discovery ran (evidenced above) and found nothing dynamic. Cite the
|
|
205
|
+
// discovery metadata IN the pass reason so the receipt itself is the evidence trail.
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
reason: `no dynamic behaviors discovered — discovery ran: ${d.elementsScanned} elements scanned, scroll swept ${d.scrollSweep.from}→${d.scrollSweep.to}px in ${d.scrollSweep.steps} steps, observed ${d.observeMs}ms` + (d.hoverTriggersProbed && d.hoverTriggersProbed.length ? `, hover-probed [${d.hoverTriggersProbed.join(", ")}]` : ""),
|
|
209
|
+
artifact: livePath,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!exists(clonePath))
|
|
214
|
+
return { ok: false, reason: `targets/${name}/behaviors-clone.json missing — capture the clone (with clone/fixes.js loaded) the SAME way: ${liveKeys.length} observed live behavior(s) [${liveKeys.join(", ")}]${declaredKeys.length ? ` + ${declaredKeys.length} declared` : ""} have nothing to compare against` };
|
|
215
|
+
let clone; try { clone = readJson(clonePath); } catch (e) { return { ok: false, reason: `behaviors-clone.json is not valid JSON: ${e.message}` }; }
|
|
216
|
+
const cloneBehaviors = (clone && clone.behaviors) || {};
|
|
217
|
+
|
|
218
|
+
const devPath = path.join(dir, "behavior-deviations.json");
|
|
219
|
+
const dev = exists(devPath) ? readJson(devPath) : {};
|
|
220
|
+
|
|
221
|
+
const missing = [], outOfTolerance = [], documented = [];
|
|
222
|
+
for (const key of liveKeys) {
|
|
223
|
+
if (dev[key] && typeof dev[key].reason === "string" && dev[key].reason.trim()) { documented.push(key); continue; }
|
|
224
|
+
if (!(key in cloneBehaviors)) { missing.push(key); continue; }
|
|
225
|
+
const liveB = live.behaviors[key], cloneB = cloneBehaviors[key];
|
|
226
|
+
if (liveB.trigger && cloneB.trigger && liveB.trigger !== cloneB.trigger) {
|
|
227
|
+
outOfTolerance.push(`${key} (trigger: live="${liveB.trigger}" clone="${cloneB.trigger}")`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const cmp = compareMeasured(key, liveB.kind, liveB.measured, cloneB.measured);
|
|
231
|
+
if (!cmp.ok) outOfTolerance.push(`${key} (${cmp.detail})`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (missing.length)
|
|
235
|
+
return { ok: false, reason: `${missing.length} live behavior(s) MISSING from the clone (silently unreproduced, undocumented): ${missing.join(", ")} — reproduce in clone/fixes.js or document why in targets/${name}/behavior-deviations.json` };
|
|
236
|
+
if (outOfTolerance.length)
|
|
237
|
+
return { ok: false, reason: `${outOfTolerance.length} behavior(s) reproduced but OUT OF TOLERANCE: ${outOfTolerance.join("; ")}` };
|
|
238
|
+
|
|
239
|
+
// Declared rows: reproduced when the CLONE's dynamic pass observed the same element
|
|
240
|
+
// firing (descriptor match under any observed prefix — the clone made it move; which
|
|
241
|
+
// trigger class it landed under is secondary), else they need a reasoned deviation.
|
|
242
|
+
// Never silently dropped — that's how one invented animation quietly replaces two.
|
|
243
|
+
let declaredExcused = 0;
|
|
244
|
+
if (declaredKeys.length) {
|
|
245
|
+
const descriptorOf = (k) => k.replace(/^[a-z-]+:/i, "");
|
|
246
|
+
const cloneDescriptors = new Set(Object.keys(cloneBehaviors).map(descriptorOf));
|
|
247
|
+
const undisposed = [];
|
|
248
|
+
for (const k of declaredKeys) {
|
|
249
|
+
if (dev[k] && typeof dev[k].reason === "string" && dev[k].reason.trim()) { declaredExcused++; continue; }
|
|
250
|
+
if (!cloneDescriptors.has(descriptorOf(k))) undisposed.push(k);
|
|
251
|
+
}
|
|
252
|
+
if (undisposed.length)
|
|
253
|
+
return { ok: false, reason: `${undisposed.length} DECLARED behavior(s) with NO disposition — markers say these are supposed to move; nothing reproduces or excuses them: ${undisposed.slice(0, 6).join(", ")}${undisposed.length > 6 ? ` … +${undisposed.length - 6} more` : ""} — run: node tools/behavior-worksheet.js ${name} (it prints ready-to-send poll questions for the human)` };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const summary = `${liveKeys.length} live behavior(s) verified — ${liveKeys.length - documented.length} reproduced within tolerance` + (documented.length ? `, ${documented.length} documented deviation(s) [${documented.join(", ")}]` : "") + (declaredKeys.length ? `; ${declaredKeys.length} declared row(s) disposed (${declaredKeys.length - declaredExcused} reproduced, ${declaredExcused} excused)` : "");
|
|
257
|
+
return { ok: true, reason: summary, artifact: clonePath };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── PHASES — ordered; each with an objective gate ────────────────────────────
|
|
261
|
+
// kind: "machine" → fully verified by the gate (exit 0 is a fact)
|
|
262
|
+
// "attested" → can't be fully machine-checked; requires --evidence and is flagged
|
|
263
|
+
const PHASES = [
|
|
264
|
+
{
|
|
265
|
+
key: "target",
|
|
266
|
+
title: "Target + viewport fixed",
|
|
267
|
+
kind: "machine",
|
|
268
|
+
gate(name) {
|
|
269
|
+
const tp = path.join(targetDir(name), "target.json");
|
|
270
|
+
if (!exists(tp)) return { ok: false, reason: `targets/${name}/target.json missing — run new-target.js first` };
|
|
271
|
+
const t = readJson(tp);
|
|
272
|
+
if (!t.url || !t.width) return { ok: false, reason: "target.json needs both url and width" };
|
|
273
|
+
return { ok: true, reason: `target ${t.url} @ ${t.width}px`, artifact: tp };
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
key: "assets",
|
|
278
|
+
title: "Real assets extracted (fonts/icons/logo)",
|
|
279
|
+
kind: "attested", // "not hand-drawn" isn't machine-provable; we light-check woff2 magic + require evidence
|
|
280
|
+
gate(name) {
|
|
281
|
+
// Light machine check: any shipped .woff2 must have real wOF2 magic bytes (catches a
|
|
282
|
+
// renamed/hand-faked font). We do NOT require fonts to exist (some sites use system
|
|
283
|
+
// fonts), so a clean pass here still needs an --evidence attestation at advance time.
|
|
284
|
+
const cloneDir = path.join(targetDir(name), "clone");
|
|
285
|
+
if (!exists(cloneDir)) return { ok: false, reason: `targets/${name}/clone/ missing` };
|
|
286
|
+
const woffs = [];
|
|
287
|
+
const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const fp = path.join(d, e.name); if (e.isDirectory()) walk(fp); else if (e.name.endsWith(".woff2")) woffs.push(fp); } };
|
|
288
|
+
walk(cloneDir);
|
|
289
|
+
for (const w of woffs) {
|
|
290
|
+
const magic = fs.readFileSync(w).subarray(0, 4).toString("latin1");
|
|
291
|
+
if (magic !== "wOF2") return { ok: false, reason: `${path.relative(WORK, w)} is not a real woff2 (bad magic bytes) — extract it, don't rename` };
|
|
292
|
+
}
|
|
293
|
+
return { ok: true, reason: `${woffs.length} woff2 asset(s) validated; attest icons/logo are captured (not redrawn)`, artifact: cloneDir };
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
key: "measure",
|
|
298
|
+
title: "Live site measured at the fixed width",
|
|
299
|
+
kind: "machine",
|
|
300
|
+
gate(name) {
|
|
301
|
+
const lp = path.join(targetDir(name), "live.json");
|
|
302
|
+
if (!exists(lp)) return { ok: false, reason: `targets/${name}/live.json missing — measure the live site (RUNBOOK)` };
|
|
303
|
+
let live; try { live = readJson(lp); } catch (e) { return { ok: false, reason: `live.json is not valid JSON: ${e.message}` }; }
|
|
304
|
+
const t = readJson(path.join(targetDir(name), "target.json"));
|
|
305
|
+
if (!live.elements || !Object.keys(live.elements).length) return { ok: false, reason: "live.json has no measured elements" };
|
|
306
|
+
if (live.viewport && t.width && live.viewport.width !== t.width)
|
|
307
|
+
return { ok: false, reason: `live.json measured at ${live.viewport.width}px, target is ${t.width}px — re-measure at the fixed width` };
|
|
308
|
+
return { ok: true, reason: `${Object.keys(live.elements).length} live elements measured @ ${live.viewport && live.viewport.width}px`, artifact: lp };
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
key: "build",
|
|
313
|
+
title: "Clone built to spec + captured",
|
|
314
|
+
kind: "machine",
|
|
315
|
+
gate(name) {
|
|
316
|
+
const html = path.join(targetDir(name), "clone", "index.html");
|
|
317
|
+
if (!exists(html)) return { ok: false, reason: "clone/index.html missing" };
|
|
318
|
+
if (/TODO: build to spec/.test(fs.readFileSync(html, "utf8"))) return { ok: false, reason: "clone/index.html still has the scaffold TODO — build the header first" };
|
|
319
|
+
const cp = path.join(targetDir(name), "clone.json");
|
|
320
|
+
if (!exists(cp)) return { ok: false, reason: `targets/${name}/clone.json missing — capture the clone (RUNBOOK)` };
|
|
321
|
+
let clone; try { clone = readJson(cp); } catch (e) { return { ok: false, reason: `clone.json is not valid JSON: ${e.message}` }; }
|
|
322
|
+
if (!clone.elements || !Object.keys(clone.elements).length) return { ok: false, reason: "clone.json has no measured elements" };
|
|
323
|
+
return { ok: true, reason: `${Object.keys(clone.elements).length} clone elements captured`, artifact: cp };
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
key: "visual",
|
|
328
|
+
title: "--visual gate green (pixels match)",
|
|
329
|
+
kind: "machine",
|
|
330
|
+
gate(name) {
|
|
331
|
+
const lp = path.join(targetDir(name), "live.json"), cp2 = path.join(targetDir(name), "clone.json");
|
|
332
|
+
if (!exists(lp) || !exists(cp2)) return { ok: false, reason: "live.json / clone.json missing — measure + build first" };
|
|
333
|
+
const live = readJson(lp);
|
|
334
|
+
const clone = readJson(cp2);
|
|
335
|
+
if (live.viewport && clone.viewport && live.viewport.width !== clone.viewport.width)
|
|
336
|
+
return { ok: false, reason: `viewport widths differ (${live.viewport.width} vs ${clone.viewport.width}) — x-positions not comparable` };
|
|
337
|
+
const v = diffSnapshots(live, clone, { visual: true });
|
|
338
|
+
if (!v.ok) return { ok: false, reason: `${v.summary.failures}/${v.summary.comparisons} --visual comparisons over ${v.summary.tol}px — run score.js for the fix list` };
|
|
339
|
+
return { ok: true, reason: `--visual PASS — ${v.summary.comparisons} comparisons, 0 fails`, artifact: path.join(targetDir(name), "clone.json") };
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
key: "coverage",
|
|
344
|
+
title: "Coverage closed (every painted leaf targeted)",
|
|
345
|
+
kind: "machine",
|
|
346
|
+
gate(name) {
|
|
347
|
+
// Requires targets/<name>/coverage.json: the enumerated painted leaves in the region.
|
|
348
|
+
// Accepts ["logo","nav_first",...] or {leaves:[...]}. The gate verifies every enumerated
|
|
349
|
+
// leaf has a MEASURED target on BOTH pages — a green --visual only proves the elements you
|
|
350
|
+
// measured, so an uncovered painted leaf is unverified, not matched (PLAYBOOK 5b).
|
|
351
|
+
const covPath = path.join(targetDir(name), "coverage.json");
|
|
352
|
+
if (!exists(covPath)) return { ok: false, reason: `targets/${name}/coverage.json missing — enumerate every painted leaf in the region (own text / background-image / <svg>) and list them` };
|
|
353
|
+
let cov; try { cov = readJson(covPath); } catch (e) { return { ok: false, reason: `coverage.json is not valid JSON: ${e.message}` }; }
|
|
354
|
+
const leaves = Array.isArray(cov) ? cov : cov.leaves;
|
|
355
|
+
if (!Array.isArray(leaves) || !leaves.length) return { ok: false, reason: "coverage.json must be a non-empty array of painted-leaf names" };
|
|
356
|
+
const live = readJson(path.join(targetDir(name), "live.json")).elements || {};
|
|
357
|
+
const clone = readJson(path.join(targetDir(name), "clone.json")).elements || {};
|
|
358
|
+
const uncovered = leaves.filter((n) => !(live[n] && live[n].present && clone[n] && clone[n].present));
|
|
359
|
+
if (uncovered.length) return { ok: false, reason: `${uncovered.length} painted leaf/leaves have no measured target on both pages: ${uncovered.join(", ")}` };
|
|
360
|
+
return { ok: true, reason: `all ${leaves.length} enumerated painted leaves are covered`, artifact: covPath };
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: "strict",
|
|
365
|
+
title: "Strict deltas fixed or documented",
|
|
366
|
+
kind: "machine",
|
|
367
|
+
gate(name) {
|
|
368
|
+
// Strict mode also flags structural CSS (display/position/gap/padding/font-family alias).
|
|
369
|
+
// Two valid implementations can legitimately differ there — but each such delta must be
|
|
370
|
+
// DOCUMENTED, never silent (PLAYBOOK ground rule 5). Pass when strict fails are 0, OR every
|
|
371
|
+
// failing (target, prop) is listed in targets/<name>/deviations.json:
|
|
372
|
+
// { "nav_first": { "layout.display": "flex vs inline-block — same pixels", ... }, ... }
|
|
373
|
+
const lp = path.join(targetDir(name), "live.json"), cp2 = path.join(targetDir(name), "clone.json");
|
|
374
|
+
if (!exists(lp) || !exists(cp2)) return { ok: false, reason: "live.json / clone.json missing — earlier phases first" };
|
|
375
|
+
const live = readJson(lp);
|
|
376
|
+
const clone = readJson(cp2);
|
|
377
|
+
const s = diffSnapshots(live, clone, {});
|
|
378
|
+
const fails = s.rows.filter((r) => !r.pass);
|
|
379
|
+
if (!fails.length) return { ok: true, reason: "strict PASS — 0 structural deltas", artifact: cp2 };
|
|
380
|
+
// A delta that ALSO fails --visual is a painted mark, not structure — it can never be
|
|
381
|
+
// "documented away" (PLAYBOOK ground rule 6: a colour/underline delta is never structural).
|
|
382
|
+
const v = diffSnapshots(live, clone, { visual: true });
|
|
383
|
+
const paintKeys = new Set(v.rows.filter((r) => !r.pass).map((r) => r.target + "\u0000" + r.prop));
|
|
384
|
+
const paint = fails.filter((r) => paintKeys.has(r.target + "\u0000" + r.prop));
|
|
385
|
+
if (paint.length)
|
|
386
|
+
return { ok: false, reason: `${paint.length} PAINT delta(s) — visible marks can never be documented away, fix them: ` + paint.map((r) => `${r.target}.${r.prop}`).join(", ") };
|
|
387
|
+
const devPath = path.join(targetDir(name), "deviations.json");
|
|
388
|
+
const dev = exists(devPath) ? readJson(devPath) : {};
|
|
389
|
+
const undocumented = fails.filter((r) => !(dev[r.target] && dev[r.target][r.prop]));
|
|
390
|
+
if (undocumented.length)
|
|
391
|
+
return { ok: false, reason: `${undocumented.length} strict delta(s) undocumented — fix them or explain each in deviations.json: ` + undocumented.map((r) => `${r.target}.${r.prop}`).join(", ") };
|
|
392
|
+
return { ok: true, reason: `${fails.length} structural delta(s), all documented in deviations.json`, artifact: devPath };
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
key: "behavior",
|
|
397
|
+
title: "JS-driven dynamics reproduced (measured, not eyeballed)",
|
|
398
|
+
kind: "machine",
|
|
399
|
+
gate(name) { return behaviorGate(name); },
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
key: "human",
|
|
403
|
+
title: "Human QA approved (PingHumans side-by-side)",
|
|
404
|
+
kind: "machine",
|
|
405
|
+
gate(name) {
|
|
406
|
+
// The gates prove what the tool measures; a human proves the measured set is what a
|
|
407
|
+
// person actually SEES (the LEARNINGS gate-vs-eyes split). A PingHumans verdict is
|
|
408
|
+
// machine-checkable, so this is a machine gate: verify re-fetches the LATEST round's
|
|
409
|
+
// verdict from the API (a cached approval is never trusted) and exits 0 only on
|
|
410
|
+
// human approval. Scope-pinned test template + refile loop: harness/human-qa.js.
|
|
411
|
+
const hq = path.join(targetDir(name), "human-qa.json");
|
|
412
|
+
if (!exists(hq)) return { ok: false, reason: `no human QA round recorded — tunnel the clone to a public URL, then: node harness/human-qa.js file ${name} --draft <public-url>` };
|
|
413
|
+
const r = require("child_process").spawnSync(
|
|
414
|
+
process.execPath, [path.join(PKG, "harness", "human-qa.js"), "verify", name],
|
|
415
|
+
{ encoding: "utf8", cwd: WORK, timeout: 30_000 }
|
|
416
|
+
);
|
|
417
|
+
const line = ((r.stdout || "") + (r.stderr || "")).trim().split("\n")[0] || "verify produced no output";
|
|
418
|
+
if (r.status !== 0) return { ok: false, reason: line };
|
|
419
|
+
return { ok: true, reason: line, artifact: hq };
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
key: "done",
|
|
424
|
+
title: "Pixel-perfect — verified end to end",
|
|
425
|
+
kind: "machine",
|
|
426
|
+
gate(name) {
|
|
427
|
+
// Default-FAIL final verification: a recorded pass is NOT trusted — every earlier gate is
|
|
428
|
+
// RE-RUN against the current artifacts, so a pass can't survive later edits to what it
|
|
429
|
+
// certified (e.g. re-capturing the clone to close coverage and regressing the visuals).
|
|
430
|
+
// Merged snapshots (tools/merge-snapshot.js) are ITERATION artifacts: elements outside
|
|
431
|
+
// the re-captured subset carry stale measurements a fix may have displaced (astryx's
|
|
432
|
+
// bento-height fix moved the footer). done demands one final FULL capture of each —
|
|
433
|
+
// a full re-capture overwrites the file and clears the stamp.
|
|
434
|
+
for (const f of ["live.json", "clone.json"]) {
|
|
435
|
+
const p = path.join(targetDir(name), f);
|
|
436
|
+
if (exists(p)) {
|
|
437
|
+
try {
|
|
438
|
+
const snap = readJson(p);
|
|
439
|
+
if (snap.merged) return { ok: false, reason: `${f} is a MERGED iteration snapshot (partial re-capture folded in at ${snap.merged.at}) — take one full capture before claiming done` };
|
|
440
|
+
} catch (e) { /* unreadable snapshots fail their own gates */ }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const st = loadState(name);
|
|
444
|
+
const missing = [], stale = [], forcedPhases = [];
|
|
445
|
+
for (const p of PHASES.slice(0, -1)) {
|
|
446
|
+
const ph = st.phases[p.key];
|
|
447
|
+
if (ph.status !== "pass") { missing.push(p.key); continue; }
|
|
448
|
+
if (ph.forced) forcedPhases.push(p.key);
|
|
449
|
+
const g = safeGate(p, name);
|
|
450
|
+
if (!g.ok) stale.push(`${p.key} — ${g.reason}`);
|
|
451
|
+
}
|
|
452
|
+
if (missing.length) return { ok: false, reason: `earlier phases not done: ${missing.join(", ")}` };
|
|
453
|
+
if (stale.length) return { ok: false, reason: `recorded pass(es) are STALE against current artifacts — fix and re-advance: ${stale.join("; ")}` };
|
|
454
|
+
if (forcedPhases.length) return { ok: false, reason: `phase(s) were forced past enforcement: ${forcedPhases.join(", ")} — re-advance each with a passing gate before claiming done` };
|
|
455
|
+
return { ok: true, reason: "every phase gate re-verified green against current artifacts, in order, none forced" };
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
const PHASE_BY_KEY = Object.fromEntries(PHASES.map((p) => [p.key, p]));
|
|
460
|
+
const phaseIndex = (key) => PHASES.findIndex((p) => p.key === key);
|
|
461
|
+
|
|
462
|
+
// ── state ─────────────────────────────────────────────────────────────────────
|
|
463
|
+
function initWorkflow(name, url, width, opts) {
|
|
464
|
+
const dir = targetDir(name);
|
|
465
|
+
if (!exists(dir)) { fs.mkdirSync(dir, { recursive: true }); }
|
|
466
|
+
const sp = statePath(name);
|
|
467
|
+
// Re-seeding destroys recorded phase state — never silent. Refuse unless --force, and
|
|
468
|
+
// receipt the reset in the ledger so the audit trail can explain an all-pending workflow.
|
|
469
|
+
if (exists(sp)) {
|
|
470
|
+
if (!(opts && opts.force)) {
|
|
471
|
+
console.error(`❌ workflow for "${name}" already exists — refusing to reset recorded phase state.\n Re-seed (e.g. after corruption) with: ${CMD} init ${name} --force — the reset is receipted in the ledger.`);
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
appendLedger(name, { ts: new Date().toISOString(), event: "reset", phase: null, runId: runId(), gate: null, forced: true, reason: "ppk init --force re-seeded workflow.json (prior phase state discarded; ledger retained)" });
|
|
475
|
+
}
|
|
476
|
+
// prefer target.json for canonical url/width if it exists
|
|
477
|
+
const tp = path.join(dir, "target.json");
|
|
478
|
+
if (exists(tp)) { const t = readJson(tp); url = url || t.url; width = width || t.width; }
|
|
479
|
+
const state = {
|
|
480
|
+
name, url: url || null, width: width || null,
|
|
481
|
+
createdAt: new Date().toISOString(),
|
|
482
|
+
phaseOrder: PHASES.map((p) => p.key),
|
|
483
|
+
phases: Object.fromEntries(PHASES.map((p) => [p.key, { status: "pending", kind: p.kind, runId: null, sha256: null, evidence: null, ts: null, forced: false }])),
|
|
484
|
+
};
|
|
485
|
+
fs.writeFileSync(sp, JSON.stringify(state, null, 2) + "\n");
|
|
486
|
+
return state;
|
|
487
|
+
}
|
|
488
|
+
function loadState(name) {
|
|
489
|
+
const p = statePath(name);
|
|
490
|
+
if (!exists(p)) { console.error(`no workflow for "${name}" — run: ${CMD} init ${name}`); process.exit(1); }
|
|
491
|
+
// State-corruption recovery: a truncated/hand-edited state file gets a self-describing
|
|
492
|
+
// error + the recovery command, never a raw parse stack. The ledger stays intact either way.
|
|
493
|
+
let st;
|
|
494
|
+
try { st = readJson(p); } catch (e) {
|
|
495
|
+
console.error(`❌ targets/${name}/workflow.json is corrupt: ${e.message}\n The audit ledger (workflow.jsonl) is untouched. Re-seed state with: ${CMD} init ${name} --force (receipted as a reset), then re-advance each phase — gates re-verify from the artifacts.`);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
const broken = !st || typeof st !== "object" || !st.phases;
|
|
499
|
+
if (!broken) {
|
|
500
|
+
// Schema MIGRATION, not corruption: a workflow seeded before the kit gained a phase
|
|
501
|
+
// (e.g. `human`) is missing that key. Hydrate it as pending IN MEMORY — recorded
|
|
502
|
+
// receipts stay valid, the new phase simply isn't done yet. Persisted naturally by
|
|
503
|
+
// the next advance's saveState; reads (status/gate) stay side-effect-free.
|
|
504
|
+
for (const p of PHASES) {
|
|
505
|
+
if (!st.phases[p.key]) st.phases[p.key] = { status: "pending", kind: p.kind, runId: null, sha256: null, evidence: null, ts: null, forced: false };
|
|
506
|
+
}
|
|
507
|
+
st.phaseOrder = PHASES.map((p) => p.key);
|
|
508
|
+
}
|
|
509
|
+
const invalid = broken || PHASES.some((ph) => typeof st.phases[ph.key].status !== "string");
|
|
510
|
+
if (invalid) {
|
|
511
|
+
console.error(`❌ targets/${name}/workflow.json has an invalid shape (missing/unknown phase state — older schema or manual edit).\n Re-seed with: ${CMD} init ${name} --force (receipted as a reset), then re-advance each phase.`);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
return st;
|
|
515
|
+
}
|
|
516
|
+
const saveState = (name, st) => fs.writeFileSync(statePath(name), JSON.stringify(st, null, 2) + "\n");
|
|
517
|
+
const appendLedger = (name, rec) => fs.appendFileSync(ledgerPath(name), JSON.stringify(rec) + "\n");
|
|
518
|
+
|
|
519
|
+
// ── commands ────────────────────────────────────────────────────────────────
|
|
520
|
+
function cmdStatus(name) {
|
|
521
|
+
const st = loadState(name);
|
|
522
|
+
// target.json is the CANONICAL url/width (the target gate reads it); workflow.json only
|
|
523
|
+
// snapshots them at init. Display the live values so a legitimately re-targeted width
|
|
524
|
+
// (e.g. a browser that couldn't hit the requested viewport) isn't shown stale mid-run.
|
|
525
|
+
let cur = { url: st.url, width: st.width };
|
|
526
|
+
try { const t = readJson(path.join(targetDir(name), "target.json")); cur = { url: t.url || cur.url, width: t.width || cur.width }; } catch (e) { /* fall back to the init snapshot */ }
|
|
527
|
+
console.log(`\nworkflow — ${name} (${cur.url || "?"} @ ${cur.width || "?"}px)`);
|
|
528
|
+
let nextRequired = null;
|
|
529
|
+
for (const p of PHASES) {
|
|
530
|
+
const ph = st.phases[p.key];
|
|
531
|
+
const mark = ph.status === "pass" ? (ph.forced ? "⚠ forced" : (p.kind === "attested" ? "✓ attested" : "✓ pass")) : "· pending";
|
|
532
|
+
console.log(` ${mark.padEnd(11)} ${p.key.padEnd(9)} ${p.title}`);
|
|
533
|
+
if (!nextRequired && ph.status !== "pass") nextRequired = p;
|
|
534
|
+
}
|
|
535
|
+
if (nextRequired) {
|
|
536
|
+
const g = safeGate(nextRequired, name);
|
|
537
|
+
console.log(`\n next: ${nextRequired.key} — ${nextRequired.title}`);
|
|
538
|
+
console.log(` gate: ${g.ok ? "READY (gate passes) → advance it" : "blocked — " + g.reason}`);
|
|
539
|
+
console.log(` run: ${CMD} advance ${name} ${nextRequired.key}` + (nextRequired.kind === "attested" ? ' --evidence "…"' : ""));
|
|
540
|
+
} else {
|
|
541
|
+
console.log(`\n ✓ all phases passed — this clone is verified pixel-perfect end to end.`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function safeGate(phase, name) {
|
|
546
|
+
try { return phase.gate(name); } catch (e) { return { ok: false, reason: `gate errored: ${e.message}` }; }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function cmdGate(name, phaseKey) {
|
|
550
|
+
const phase = PHASE_BY_KEY[phaseKey];
|
|
551
|
+
if (!phase) { console.error(`unknown phase "${phaseKey}". phases: ${PHASES.map((p) => p.key).join(", ")}`); process.exit(2); }
|
|
552
|
+
loadState(name); // ensure workflow exists
|
|
553
|
+
const g = safeGate(phase, name);
|
|
554
|
+
console.log(`${g.ok ? "✓ PASS" : "❌ FAIL"} ${phaseKey} — ${g.reason}`);
|
|
555
|
+
process.exit(g.ok ? 0 : 1);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function cmdAdvance(name, phaseKey, opts) {
|
|
559
|
+
const phase = PHASE_BY_KEY[phaseKey];
|
|
560
|
+
if (!phase) { console.error(`unknown phase "${phaseKey}". phases: ${PHASES.map((p) => p.key).join(", ")}`); process.exit(2); }
|
|
561
|
+
const st = loadState(name);
|
|
562
|
+
const idx = phaseIndex(phaseKey);
|
|
563
|
+
|
|
564
|
+
// A refused advance is receipted too — the audit trail records rejected overrides and
|
|
565
|
+
// failed attempts, not just successes (an agent probing the gates leaves a trace).
|
|
566
|
+
const refuse = (why, hint) => {
|
|
567
|
+
appendLedger(name, { ts: new Date().toISOString(), phase: phaseKey, runId: runId(), gate: "refused", forced: !!opts.force, reason: why });
|
|
568
|
+
console.error(`❌ ${why}`);
|
|
569
|
+
if (hint) console.error(` ${hint}`);
|
|
570
|
+
process.exit(1);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// `forced` means "--force actually bypassed an enforcement" — ANY of the three checks
|
|
574
|
+
// (ordering, attestation evidence, the gate itself), not only a failing gate. Each bypass
|
|
575
|
+
// is named in `overrode` so the ledger shows exactly what was skipped.
|
|
576
|
+
const overrode = [];
|
|
577
|
+
|
|
578
|
+
const earlierPending = PHASES.slice(0, idx).filter((p) => st.phases[p.key].status !== "pass").map((p) => p.key);
|
|
579
|
+
if (earlierPending.length) {
|
|
580
|
+
if (!opts.force) refuse(`cannot advance "${phaseKey}" — earlier phase(s) not done: ${earlierPending.join(", ")}`, `do them in order, or override with --force (recorded in the audit log).`);
|
|
581
|
+
overrode.push("order");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (phase.kind === "attested" && !opts.evidence) {
|
|
585
|
+
if (!opts.force) refuse(`"${phaseKey}" is an attestation phase — pass --evidence "what you verified" (its gate can't fully prove it).`);
|
|
586
|
+
overrode.push("evidence");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const g = safeGate(phase, name);
|
|
590
|
+
if (!g.ok) {
|
|
591
|
+
if (!opts.force) refuse(`gate failed for "${phaseKey}": ${g.reason}`, `fix it, or override with --force (recorded as forced in the audit log).`);
|
|
592
|
+
overrode.push("gate");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const rec = {
|
|
596
|
+
ts: new Date().toISOString(),
|
|
597
|
+
phase: phaseKey,
|
|
598
|
+
runId: runId(),
|
|
599
|
+
gate: g.ok ? "pass" : "failed",
|
|
600
|
+
forced: overrode.length > 0,
|
|
601
|
+
overrode,
|
|
602
|
+
sha256: g.artifact ? sha256OfFile(g.artifact) : null,
|
|
603
|
+
artifact: g.artifact ? path.relative(WORK, g.artifact) : null,
|
|
604
|
+
evidence: opts.evidence || null,
|
|
605
|
+
reason: g.reason,
|
|
606
|
+
};
|
|
607
|
+
st.phases[phaseKey] = { status: "pass", kind: phase.kind, runId: rec.runId, sha256: rec.sha256, evidence: rec.evidence, ts: rec.ts, forced: rec.forced, overrode };
|
|
608
|
+
saveState(name, st);
|
|
609
|
+
appendLedger(name, rec);
|
|
610
|
+
|
|
611
|
+
console.log(`${rec.forced ? "⚠ FORCED" : "✓"} ${phaseKey} recorded (runId ${rec.runId}${rec.sha256 ? ", sha256 " + rec.sha256 : ""})`);
|
|
612
|
+
console.log(` ${g.reason}`);
|
|
613
|
+
if (rec.forced) console.log(` ⚠ enforcement bypassed (${overrode.join(", ")}) — flagged in workflow.jsonl; the done gate refuses forced phases.`);
|
|
614
|
+
const next = PHASES.find((p) => st.phases[p.key].status !== "pass");
|
|
615
|
+
console.log(next ? ` next: ${CMD} advance ${name} ${next.key}` : ` ✓ workflow complete — verified pixel-perfect.`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function cmdLedger(name) {
|
|
619
|
+
const p = ledgerPath(name);
|
|
620
|
+
if (!exists(p)) { console.log(`no ledger yet for "${name}".`); return; }
|
|
621
|
+
const lines = fs.readFileSync(p, "utf8").trim().split("\n").filter(Boolean);
|
|
622
|
+
console.log(`\naudit trail — ${name} (${lines.length} events)`);
|
|
623
|
+
for (const l of lines) {
|
|
624
|
+
const r = JSON.parse(l);
|
|
625
|
+
const kind = r.event === "reset" ? "RESET" : r.gate === "refused" ? "refused" : r.forced ? "FORCED" : r.gate;
|
|
626
|
+
const what = r.event === "reset" ? "(state)" : r.phase;
|
|
627
|
+
const extra = (r.overrode && r.overrode.length ? ` overrode: ${r.overrode.join(",")}` : "") + (r.sha256 ? ` sha=${r.sha256}` : "") + (r.evidence ? ` ev: ${r.evidence}` : "");
|
|
628
|
+
console.log(` ${r.ts} ${String(kind).padEnd(7)} ${String(what).padEnd(9)} runId=${r.runId}${extra}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
633
|
+
function parseOpts(args) {
|
|
634
|
+
const opts = { force: args.includes("--force"), evidence: null };
|
|
635
|
+
const ei = args.indexOf("--evidence");
|
|
636
|
+
if (ei >= 0) {
|
|
637
|
+
const v = args[ei + 1];
|
|
638
|
+
// never let a flag or nothing become permanent attestation text in the append-only ledger
|
|
639
|
+
if (v == null || v.startsWith("--")) {
|
|
640
|
+
console.error(`--evidence needs a value (got ${v == null ? "nothing" : `"${v}"`}) — quote it: --evidence "what you verified"`);
|
|
641
|
+
process.exit(2);
|
|
642
|
+
}
|
|
643
|
+
opts.evidence = v;
|
|
644
|
+
}
|
|
645
|
+
return opts;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Delegate to a bundled harness/tools script, running it in the USER's cwd (so targets/
|
|
649
|
+
// land there) with the SAME runtime that launched ppk (node or bun → process.execPath).
|
|
650
|
+
function delegate(relScript, args) {
|
|
651
|
+
const cp = require("child_process");
|
|
652
|
+
// PPK_ENTRY tells the child it was invoked via the installed `ppk` command, so its
|
|
653
|
+
// printed next-step guidance can use `ppk …` (runnable) instead of `node harness/…`.
|
|
654
|
+
const r = cp.spawnSync(process.execPath, [path.join(PKG, relScript), ...args], { stdio: "inherit", cwd: WORK, env: { ...process.env, PPK_ENTRY: "1" } });
|
|
655
|
+
process.exit(r.status == null ? 1 : r.status);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const HELP = `ppk — clone a site pixel-perfect, and prove it with an enforced, gated workflow
|
|
659
|
+
|
|
660
|
+
ppk setup FIRST CONTACT — one interactive command: global
|
|
661
|
+
install, cloudflared, PingHumans login (skippable —
|
|
662
|
+
local review mode needs no account), agent skills.
|
|
663
|
+
Also: npx pixel-perfect-kit setup
|
|
664
|
+
ppk doctor read-only preflight re-check, fix command per miss
|
|
665
|
+
ppk agent-setup [--force] teach your AI agent: installs the clone-site skill
|
|
666
|
+
into ~/.claude/skills — then just ask your agent
|
|
667
|
+
to "clone https://example.com pixel-perfect"
|
|
668
|
+
ppk where print the installed kit's directory (docs live there)
|
|
669
|
+
|
|
670
|
+
ppk adopt <name> <original-url> [width] register an EXTERNALLY-BUILT draft (ditto, lovable,
|
|
671
|
+
hand-built) for the human-review loop — no pixel
|
|
672
|
+
pipeline, the human's verdict is the whole check
|
|
673
|
+
ppk new <name> <url> [width] scaffold a target + seed the workflow
|
|
674
|
+
ppk adopt <name> <original-url> [width] register an EXTERNALLY-built clone (ditto, etc.)
|
|
675
|
+
for the human-review loop only — then:
|
|
676
|
+
ppk tunnel <name> --url <dev-url> → ppk human file
|
|
677
|
+
ppk capture-build <name> [domFile] build the clone FROM the captured live DOM (default
|
|
678
|
+
build strategy — LEARNINGS #19; needs targets/<name>/dom.html,
|
|
679
|
+
captured with pxSendDom, see RUNBOOK "Build by capture")
|
|
680
|
+
ppk serve <name> [port] serve the clone + the kit's /tools
|
|
681
|
+
ppk tunnel <name> [port] [--check] public HTTPS tunnel (cloudflared), VERIFIED to serve
|
|
682
|
+
clone/index.html byte-identically before it's recorded —
|
|
683
|
+
human-qa uses it as the default --draft
|
|
684
|
+
ppk tunnel <name> --url <http://localhost:3000> tunnel an adopted build's own dev server
|
|
685
|
+
(reachability-verified; ditto/next/vite etc.)
|
|
686
|
+
ppk tunnel --sink [port] tunnel the snapshot SINK: live pages deliver captures
|
|
687
|
+
with one pxSend call even when the environment blocks
|
|
688
|
+
page→localhost (replaces the stash/chunk fallback)
|
|
689
|
+
ppk sink run the snapshot receiver (:7799)
|
|
690
|
+
ppk score <name> score live-vs-clone vs the last run
|
|
691
|
+
ppk diff <live.json> <clone.json> [--visual|--inspect|--all|--tol N] raw numeric diff
|
|
692
|
+
|
|
693
|
+
ppk human <name> file [--draft <url>] file a scope-pinned PingHumans round (the "human"
|
|
694
|
+
phase gate — verify/template/record via the same command;
|
|
695
|
+
--draft defaults to the verified tunnel)
|
|
696
|
+
ppk human <name> poll "question" [--choices "A,B"] ~$0.05 mid-round micro-check with a real
|
|
697
|
+
human (advisory — never satisfies the human gate)
|
|
698
|
+
ppk status <name> phase table + the next required action
|
|
699
|
+
ppk gate <name> <phase> run ONE gate read-only (exit 0/1)
|
|
700
|
+
ppk advance <name> <phase> [--evidence "…"] [--force] record a phase (gate must pass)
|
|
701
|
+
ppk ledger <name> the audit trail (receipts)
|
|
702
|
+
|
|
703
|
+
phases (in order): ${PHASES.map((p) => p.key).join(" → ")}
|
|
704
|
+
docs: PLAYBOOK.md (method) · WORKFLOW.md (the gates) · RUNBOOK.md (fast capture)`;
|
|
705
|
+
|
|
706
|
+
function main() {
|
|
707
|
+
const [cmd, name, ...rest] = process.argv.slice(2);
|
|
708
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); process.exit(cmd ? 0 : 2); }
|
|
709
|
+
|
|
710
|
+
// delegating subcommands make `ppk` the single entrypoint for installed users
|
|
711
|
+
switch (cmd) {
|
|
712
|
+
case "new": { if (!name || !rest[0]) { console.error("usage: ppk new <name> <url> [width]"); process.exit(2); } return delegate("harness/new-target.js", [name, ...rest]); }
|
|
713
|
+
case "capture-build": { if (!name) { console.error("usage: ppk capture-build <name> [domFile] [--qa-toolbar]"); process.exit(2); } return delegate("harness/capture-build.js", [name, ...rest]); }
|
|
714
|
+
case "human": { if (!name || !rest[0]) { console.error("usage: ppk human <name> file|template|record|verify [args]"); process.exit(2); } return delegate("harness/human-qa.js", [rest[0], name, ...rest.slice(1)]); }
|
|
715
|
+
case "serve": { if (!name) { console.error("usage: ppk serve <name> [port]"); process.exit(2); } return delegate("harness/serve.js", [name, ...rest]); }
|
|
716
|
+
case "tunnel": { if (!name) { console.error("usage: ppk tunnel <name> [port] [--check]"); process.exit(2); } return delegate("harness/tunnel.js", [name, ...rest]); }
|
|
717
|
+
case "score": { if (!name) { console.error("usage: ppk score <name>"); process.exit(2); } return delegate("harness/score.js", [name, ...rest]); }
|
|
718
|
+
case "sink": return delegate("tools/sink.js", []);
|
|
719
|
+
case "setup": return delegate("harness/setup.js", []);
|
|
720
|
+
case "doctor": return delegate("harness/doctor.js", []);
|
|
721
|
+
case "adopt": { if (!name || !rest[0]) { console.error("usage: ppk adopt <name> <original-url> [width]"); process.exit(2); } return delegate("harness/adopt.js", [name, ...rest]); }
|
|
722
|
+
case "agent-setup": return delegate("harness/agent-setup.js", [name, ...rest].filter((a) => a != null));
|
|
723
|
+
case "where": { console.log(PKG); process.exit(0); }
|
|
724
|
+
case "diff": return delegate("tools/pixel-diff.js", [name, ...rest].filter((a) => a != null)); // raw diff / --inspect (paths are cwd-relative)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!name) { console.error(`"${cmd}" needs a <name>`); process.exit(2); }
|
|
728
|
+
switch (cmd) {
|
|
729
|
+
case "init": { const [url, width] = rest.filter((a) => !a.startsWith("--")); initWorkflow(name, url, width ? +width : undefined, { force: rest.includes("--force") }); console.log(`✓ workflow initialized for ${name} — ${CMD} status ${name}`); break; }
|
|
730
|
+
case "status": cmdStatus(name); break;
|
|
731
|
+
case "gate": cmdGate(name, rest[0]); break;
|
|
732
|
+
case "advance": cmdAdvance(name, rest[0], parseOpts(rest)); break;
|
|
733
|
+
case "ledger": cmdLedger(name); break;
|
|
734
|
+
default: console.error(`unknown command "${cmd}". run: ppk help`); process.exit(2);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (require.main === module) main();
|
|
739
|
+
module.exports = { PHASES, initWorkflow, loadState, targetDir, main, sha256OfFile };
|