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.
Files changed (53) hide show
  1. package/CLONE-ALOYOGA-HEADER.md +76 -0
  2. package/CLONE-ANY-HEADER.md +106 -0
  3. package/CLONE-ANY-SITE.md +120 -0
  4. package/DEVELOP.md +144 -0
  5. package/LAUNCH-PROMPT.md +66 -0
  6. package/LEARNINGS.md +398 -0
  7. package/LICENSE +21 -0
  8. package/PLAYBOOK.md +260 -0
  9. package/README.md +219 -0
  10. package/WORKFLOW.md +257 -0
  11. package/bin/ppk +4 -0
  12. package/harness/adopt.js +52 -0
  13. package/harness/agent-setup.js +55 -0
  14. package/harness/behavior-selftest.js +243 -0
  15. package/harness/benchmarks/README.md +34 -0
  16. package/harness/benchmarks/battery.js +86 -0
  17. package/harness/benchmarks/detection-power.js +75 -0
  18. package/harness/capture-build-selftest.js +134 -0
  19. package/harness/capture-build.js +323 -0
  20. package/harness/doctor-selftest.js +58 -0
  21. package/harness/doctor.js +85 -0
  22. package/harness/fixtures/01-backdrop-color.js +51 -0
  23. package/harness/fixtures/02-line-strut.js +53 -0
  24. package/harness/fixtures/03-compat-mode.js +44 -0
  25. package/harness/fixtures/README.md +31 -0
  26. package/harness/human-qa-selftest.js +201 -0
  27. package/harness/human-qa.js +406 -0
  28. package/harness/merge-snapshot-selftest.js +56 -0
  29. package/harness/new-target.js +101 -0
  30. package/harness/regression.js +50 -0
  31. package/harness/score.js +58 -0
  32. package/harness/serve.js +149 -0
  33. package/harness/setup-selftest.js +93 -0
  34. package/harness/setup.js +158 -0
  35. package/harness/tunnel-selftest.js +76 -0
  36. package/harness/tunnel.js +241 -0
  37. package/harness/workflow-selftest.js +211 -0
  38. package/harness/workflow.js +739 -0
  39. package/package.json +40 -0
  40. package/skill/ditto-finish/SKILL.md +52 -0
  41. package/skill/pixel-perfect-clone/SKILL.md +49 -0
  42. package/tools/RUNBOOK.md +228 -0
  43. package/tools/behavior-capture.js +307 -0
  44. package/tools/behavior-worksheet.js +82 -0
  45. package/tools/browser-capture.js +240 -0
  46. package/tools/cli-selftest.js +136 -0
  47. package/tools/extract-fonts.js +133 -0
  48. package/tools/extract-icons.js +255 -0
  49. package/tools/merge-snapshot.js +56 -0
  50. package/tools/pixel-diff.js +845 -0
  51. package/tools/reassemble.js +63 -0
  52. package/tools/selftest.js +67 -0
  53. 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 };