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,406 @@
1
+ // harness/human-qa.js — the HUMAN phase: PingHumans side-by-side QA as a workflow gate.
2
+ //
3
+ // WHY THIS EXISTS. The gates prove every property the tool measures; they cannot prove
4
+ // the two things LEARNINGS reserves for eyes — that the *measured set* is what a person
5
+ // actually sees, and that techniques rasterise identically (the gate-vs-eyes split). The
6
+ // kit's end state is a clone the designer receives after "all the human iteration rounds"
7
+ // — unattended — so the human round must be a PHASE with a machine-checkable gate, not an
8
+ // operator ritual. A PingHumans verdict IS machine-checkable: this tool files the test,
9
+ // polls the verdict over the same authenticated JSON-RPC endpoint the cpyany CLI uses,
10
+ // and exits 0 only when the LATEST round is human-approved. Receipts pin ping_id+verdict.
11
+ //
12
+ // The test template encodes the lessons stripe's 8 unconverged rounds paid for:
13
+ // - SCOPE-PIN: the reviewer judges ONLY the cloned region (an open-ended side-by-side
14
+ // of a partial clone cannot converge — stripe lesson 1).
15
+ // - Behavior is INFORMATIONAL: a static clone strips JS; hover/animation differences
16
+ // are noted, never failed on (stripe lesson 2 — acceptance criteria for
17
+ // animated/generative content must be agreed up front).
18
+ // - Per-leaf compare steps are generated from coverage.json, so the human is pointed
19
+ // at exactly the marks the gates certified — detection stays human, scope stays pinned.
20
+ //
21
+ // USAGE
22
+ // node harness/human-qa.js template <name> --draft <public-url> [--region "the header"]
23
+ // print the test spec JSON (to file manually via the PingHumans MCP)
24
+ // node harness/human-qa.js file <name> --draft <public-url> [--region "…"]
25
+ // generate the spec AND file it; records the round in targets/<name>/human-qa.json
26
+ // node harness/human-qa.js record <name> <ping_id> [--approve "Verdict A,Verdict B"]
27
+ // adopt a test filed elsewhere (e.g. via the MCP) as the current round
28
+ // node harness/human-qa.js verify <name>
29
+ // fetch the LATEST round's verdict; exit 0 = approved (the workflow gate runs this)
30
+ // node harness/human-qa.js poll <name> "question" [--choices "A,B,C"]
31
+ // a MICRO-CHECK (~$0.05, blocks up to ~300s): put one small question in front of a
32
+ // human MID-ROUND — "do these 3 tiles look right now? <urls>" — before spending a
33
+ // full test round on it. With a responsive reviewer this collapses a whole
34
+ // flag→refile→wait cycle into minutes (astryx burned 4 rounds on one contested cell
35
+ // treatment). ADVISORY ONLY: polls never satisfy the human gate — the gate still
36
+ // requires an approving verdict on a full scope-pinned round. The draft/original
37
+ // urls are appended automatically when tunnel.json/target.json exist.
38
+ // node harness/human-qa.js poll-result <name> <ping_id>
39
+ // free re-fetch of a pending poll's answers
40
+ //
41
+ // AUTH: the designer's existing cpyany/PingHumans login — ~/.config/cpyany/credentials.json,
42
+ // else the Bearer header in ~/.claude.json's cpyany/pinghumans MCP entry, else
43
+ // PPK_PINGHUMANS_TOKEN. PPK_PINGHUMANS_URL overrides the API base; a file:// value serves
44
+ // canned responses from disk (offline selftests; sandboxes that block sockets).
45
+ "use strict";
46
+
47
+ const fs = require("fs");
48
+ const os = require("os");
49
+ const path = require("path");
50
+ const { fileURLToPath } = require("url");
51
+
52
+ const WORK = process.cwd();
53
+ const targetDir = (name) => path.join(WORK, "targets", name);
54
+ const hqPath = (name) => path.join(targetDir(name), "human-qa.json");
55
+ const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
56
+ const BASE = process.env.PPK_PINGHUMANS_URL || process.env.PINGHUMANS_APP_URL || "https://pinghumans.com";
57
+
58
+ function resolveToken() {
59
+ if (process.env.PPK_PINGHUMANS_TOKEN) return process.env.PPK_PINGHUMANS_TOKEN;
60
+ try {
61
+ const t = readJson(path.join(os.homedir(), ".config", "cpyany", "credentials.json")).token;
62
+ if (t) return t;
63
+ } catch (e) {}
64
+ try {
65
+ const cfg = readJson(path.join(os.homedir(), ".claude.json"));
66
+ const entry = (cfg.mcpServers || {}).cpyany || (cfg.mcpServers || {}).pinghumans;
67
+ const m = /Bearer\s+(\S+)/.exec((entry && entry.headers && (entry.headers.Authorization || entry.headers.authorization)) || "");
68
+ if (m) return m[1];
69
+ } catch (e) {}
70
+ return null;
71
+ }
72
+
73
+ // One JSON-RPC tools/call against the PingHumans MCP-over-HTTP endpoint (the same
74
+ // transport `npx cpyany wait` uses). file:// base → canned responses from disk:
75
+ // get_test_results-<ping_id>.json / request_human_test.json
76
+ //
77
+ // The LIVE api/mcp endpoint's tools/list exposes these under the "cpyany" namespace
78
+ // (`cpyany_test`, `cpyany_test_results`), not the generic names this file uses
79
+ // internally — confirmed empirically (astryx run): a live call with the generic name
80
+ // fails with "Tool request_human_test not found" even with a valid token, while
81
+ // `tools/list` on the same endpoint returns `cpyany_test`/`cpyany_test_results`/
82
+ // `cpyany_wait`/`cpyany_poll`/`cpyany_poll_results`/`cpyany_check_source`. Kept the
83
+ // internal names (and the file:// fixture filenames / selftest) unchanged — only the
84
+ // wire method name sent to the LIVE endpoint is remapped, right before the fetch.
85
+ const LIVE_TOOL_NAME = { request_human_test: "cpyany_test", get_test_results: "cpyany_test_results", ping_humans: "cpyany_poll", get_ping: "cpyany_poll_results" };
86
+ async function rpc(name, args, timeoutMs) {
87
+ if (BASE.startsWith("file://")) {
88
+ const dir = fileURLToPath(BASE);
89
+ const f = name === "get_test_results" ? `get_test_results-${args.ping_id}.json`
90
+ : name === "get_ping" ? `get_ping-${args.ping_id}.json`
91
+ : `${name}.json`;
92
+ return readJson(path.join(dir, f));
93
+ }
94
+ const token = resolveToken();
95
+ if (!token) throw new Error("no PingHumans token — run `npx cpyany setup`, or set PPK_PINGHUMANS_TOKEN");
96
+ const wireName = LIVE_TOOL_NAME[name] || name;
97
+ const res = await fetch(`${BASE}/api/mcp`, {
98
+ method: "POST",
99
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
100
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: wireName, arguments: args } }),
101
+ // ping_humans blocks server-side while a human answers (up to ~300s) — callers pass a
102
+ // matching timeout; everything else keeps the snappy default.
103
+ signal: AbortSignal.timeout(timeoutMs || 20_000),
104
+ });
105
+ const raw = await res.text();
106
+ const m = raw.match(/data: (.*)/);
107
+ const payload = JSON.parse(m ? m[1] : raw);
108
+ if (payload.error) throw new Error(payload.error.message || "MCP error");
109
+ const r = payload.result || {};
110
+ if (r.isError) throw new Error((r.content && r.content[0] && r.content[0].text) || "PingHumans returned an error");
111
+ if (r.structuredContent) return r.structuredContent;
112
+ try { return JSON.parse(r.content[0].text); } catch (e) { throw new Error("unexpected RPC response shape"); }
113
+ }
114
+
115
+ // ── the scope-pinned test template ───────────────────────────────────────────
116
+ const slugToWords = (s) => s.replace(/[_-]+/g, " ").trim();
117
+
118
+ function buildSpec(name, draftUrl, region, changelog) {
119
+ const target = readJson(path.join(targetDir(name), "target.json"));
120
+ let leaves = [];
121
+ try {
122
+ const cov = readJson(path.join(targetDir(name), "coverage.json"));
123
+ leaves = (Array.isArray(cov) ? cov : cov.leaves || []).map(slugToWords);
124
+ } catch (e) {}
125
+ const R = region || "the cloned region";
126
+ const stripped = R.replace(/^the\s+/i, "");
127
+ const cap = stripped.charAt(0).toUpperCase() + stripped.slice(1);
128
+ const verdicts = [`${cap} identical`, `${cap} slightly off`, `${cap} clearly different`];
129
+ const steps = [
130
+ { text: `Open both pages at the same window size. This test is ONLY about ${R}. Everything outside it is out of scope — ignore it for your verdict.`, check: null },
131
+ ];
132
+ // Documented deviations are surfaced TO THE REVIEWER, or the gate's escape hatch and the
133
+ // human's expectations diverge forever: astryx round 3 re-flagged bento cells that
134
+ // behavior-deviations.json had honestly excused — the reviewer had no way to know.
135
+ let deviationNote = "";
136
+ try {
137
+ const dev = readJson(path.join(targetDir(name), "behavior-deviations.json"));
138
+ // Cross-reference entries ("See <other-key> — same phenomenon") exist for the GATE's
139
+ // key-by-key bookkeeping, not for humans: astryx round 5 surfaced 20 entries of which
140
+ // 14 were "See mutation:… same phenomenon" fragments, and the 300-char budget rendered
141
+ // the whole step as unreadable mush. Only PRIMARY entries face the reviewer.
142
+ const primary = Object.fromEntries(Object.entries(dev).filter(([, v]) => !/^\s*See\s/i.test(String((v && v.reason) || ""))));
143
+ const keys = Object.keys(primary);
144
+ if (keys.length) {
145
+ // PingHumans caps a step's `text` at 300 chars. As the number of documented
146
+ // deviations grows (astryx round 4: 5, up from round 3's 3), joining a per-entry
147
+ // reason snippet can blow past that even after truncating each one — the step
148
+ // silently failed to file (Zod "too_big") instead of degrading. Budget the PREFIX
149
+ // first, then divide what's left evenly across entries so this scales to any count
150
+ // instead of hard-failing past ~2-3 deviations (kit-change candidate — flagged here
151
+ // since human-qa.js is shared kit code, fixed minimally rather than reworked).
152
+ const PREFIX = "KNOWN, INTENTIONAL differences — do NOT flag these (documented exclusions): ";
153
+ const BUDGET = 300 - PREFIX.length;
154
+ const perEntry = Math.max(20, Math.floor((BUDGET - (keys.length - 1) * 3) / keys.length));
155
+ const lines = Object.entries(primary).map(([k, v]) => {
156
+ const label = k.replace(/^[a-z]+:/, "");
157
+ const reason = String((v && v.reason) || "");
158
+ const snippet = `${label} — ${reason}`;
159
+ return snippet.length > perEntry ? snippet.slice(0, Math.max(0, perEntry - 1)) + "…" : snippet;
160
+ });
161
+ steps.push({ text: (PREFIX + lines.join(" | ")).slice(0, 300), check: null });
162
+ // `instructions` is a SEPARATE field with its own hard cap (observed: 1000 chars
163
+ // total, shared with the fixed scope/verdict text below) — it is NOT the same budget
164
+ // as the step's 300-char cap. Duplicating the full per-entry list here (as an earlier
165
+ // version did) silently blew past 1000 once deviation count grew past ~15 (iphone17
166
+ // round 5: 34 entries -> 1152 chars -> PingHumans rejected the whole filing with a
167
+ // Zod "too_big" error, not a graceful truncation). Fix: `instructions` gets a SHORT,
168
+ // fixed-size pointer to the dedicated step above (which already carries the full,
169
+ // properly-budgeted list) instead of re-deriving its own copy from `keys.length`.
170
+ deviationNote = ` See the "KNOWN, INTENTIONAL differences" step below (${keys.length} documented) — you should NOT flag those; they're already excused.`;
171
+ }
172
+ } catch (e) {}
173
+ // one compare step per ~5 leaves, so the reviewer is pointed at exactly the
174
+ // certified marks without a 20-step checklist
175
+ for (let i = 0; i < leaves.length; i += 5) {
176
+ steps.push({
177
+ text: `Compare these elements between clone and original — position, size, color, font, spacing: ${leaves.slice(i, i + 5).join(", ")}.`,
178
+ options: ["Identical", "Slightly off", "Clearly different"],
179
+ });
180
+ }
181
+ steps.push(
182
+ { text: `Squint test: at a glance, could you tell which is the clone? Compare overall font weight/sharpness in ${R}.`, options: ["Could not tell apart", "Subtle difference", "Obvious difference"] },
183
+ { text: "INFORMATIONAL (does not affect your verdict): the clone is a static snapshot — hover menus and animations may not run. Note any such difference; do not fail the test for it.", options: ["Same on both", "Different", "Didn't check"] },
184
+ { text: `If anything in ${R} looked off, describe exactly what and where in your notes. Attach a screenshot showing both.`, check: null },
185
+ // Workers use the comment-pin flow and skip the verdict buttons unless told — 3 of the
186
+ // first 3 real responses were comment-only (choice:null), which the gate rightly refuses
187
+ // but which stalls the loop. The pick is therefore an explicit, REQUIRED final step.
188
+ { text: `FINAL REQUIRED STEP: pick one of the verdict buttons ("${verdicts[0]}" / "${verdicts[1]}" / "${verdicts[2]}"). A review without a verdict pick cannot be accepted — comments alone don't count.`, check: null }
189
+ );
190
+ // What changed since the reviewer's last round, stated UP FRONT — iphone17 round 10's
191
+ // verdict was literally "did you fix anything?": the round's substantive change was
192
+ // invisible without being told where to look. A refile that doesn't say what changed
193
+ // wastes the reviewer's pass.
194
+ let changeNote = "";
195
+ if (changelog && changelog.trim()) {
196
+ changeNote = ` CHANGED SINCE YOUR LAST REVIEW: ${changelog.trim().slice(0, 200)}`;
197
+ // step text caps at 300 chars API-side: budget = 300 − prefix, never prefix + 250
198
+ // (52 + 250 = 302 failed a live filing — iphone17 round 13)
199
+ const CHG_PREFIX = "Changed since the last review — check these first: ";
200
+ steps.splice(1, 0, { text: CHG_PREFIX + changelog.trim().slice(0, 300 - CHG_PREFIX.length), check: null });
201
+ }
202
+ return {
203
+ url: target.url,
204
+ draft_url: draftUrl,
205
+ title: `Compare ${R}: clone vs original`,
206
+ instructions: `Compare ONLY ${R} between the original site and the clone, side by side at the same width. Everything outside ${R} is out of scope.${changeNote}${deviationNote} When done, you MUST pick a verdict button — a comment-only review cannot be accepted.`,
207
+ steps,
208
+ verdict_options: verdicts,
209
+ approve_verdicts: [verdicts[0]],
210
+ n_target: 1,
211
+ deadline_seconds: 86400,
212
+ require_evidence: "screenshot",
213
+ };
214
+ }
215
+
216
+ // ── round state ───────────────────────────────────────────────────────────────
217
+ const loadHq = (name) => (fs.existsSync(hqPath(name)) ? readJson(hqPath(name)) : { rounds: [] });
218
+ const saveHq = (name, hq) => fs.writeFileSync(hqPath(name), JSON.stringify(hq, null, 2) + "\n");
219
+
220
+ function pushRound(name, ping_id, spec, approve) {
221
+ const hq = loadHq(name);
222
+ hq.rounds.push({ ping_id, draft_url: (spec && spec.draft_url) || null, region: (spec && spec.title) || null, approve_verdicts: approve, filed_at: new Date().toISOString(), last: null, checked_at: null });
223
+ saveHq(name, hq);
224
+ return hq.rounds.length;
225
+ }
226
+
227
+ // ── commands ──────────────────────────────────────────────────────────────────
228
+ async function main() {
229
+ const args = process.argv.slice(2);
230
+ const opt = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : null; };
231
+ const [cmd, name, extra] = args.filter((a, i) => !a.startsWith("--") && (i === 0 || !args[i - 1].startsWith("--")));
232
+ if (!cmd || !name) { console.error("usage: human-qa.js template|file|record|verify <name> … (see file header)"); process.exit(2); }
233
+ if (!fs.existsSync(path.join(targetDir(name), "target.json"))) { console.error(`targets/${name}/target.json missing`); process.exit(1); }
234
+
235
+ if (cmd === "template" || cmd === "file") {
236
+ // A test filed while earlier gates are still red reviews an UNFINISHED clone — the
237
+ // worker returns flags you already knew about and a credit is burned (paid for on
238
+ // astryx round 2: filed before the behavior phase ran, racing the worker's claim).
239
+ // Filing isn't a phase advance, so the state machine can't refuse it — this check is
240
+ // that refusal. Canonical phase list comes from workflow.js (a workflow.json seeded
241
+ // before a phase existed simply lacks its key — that phase is PENDING, not exempt);
242
+ // no workflow.json at all = standalone usage, guard skipped.
243
+ if (cmd === "file" && !args.includes("--anyway")) {
244
+ const wfPath = path.join(targetDir(name), "workflow.json");
245
+ if (fs.existsSync(wfPath)) {
246
+ const { PHASES } = require("./workflow.js");
247
+ const st = readJson(wfPath);
248
+ const beforeHuman = PHASES.slice(0, PHASES.findIndex((p) => p.key === "human")).map((p) => p.key);
249
+ const pending = beforeHuman.filter((k) => !(st.phases && st.phases[k] && st.phases[k].status === "pass"));
250
+ if (pending.length) {
251
+ console.error(`❌ refusing to file — earlier phase(s) not passed: ${pending.join(", ")}. A worker reviewing an unfinished clone returns flags you already know about and burns a credit.\n finish the gates first (node harness/workflow.js status ${name}), or override with --anyway if this round is deliberately out-of-band.`);
252
+ process.exit(1);
253
+ }
254
+ }
255
+ } else if (cmd === "file" && args.includes("--anyway")) {
256
+ console.error("⚠ --anyway: filing without the pre-human gates all green — deliberate out-of-band round");
257
+ }
258
+ // LOCAL review mode (--local): no PingHumans account, no tunnel, no credits — the
259
+ // kit's own serve hosts the review page at /__review (clone in an iframe, click-to-
260
+ // pin, the SAME verdict-button contract), and the submission lands in human-qa.json
261
+ // where verify reads it. The trust model is explicit: a local verdict is
262
+ // OPERATOR-trusted (an agent with browser control could forge it — PingHumans is the
263
+ // independent option), so the round records provider:"local" and every receipt built
264
+ // on it says so. Agents must never open or submit /__review themselves.
265
+ const local = args.includes("--local");
266
+ let draft = opt("--draft");
267
+ if (!local) {
268
+ // Default --draft to the tunnel this target RECORDED (harness/tunnel.js writes it
269
+ // only after byte-verifying it serves the clone).
270
+ const tp = path.join(targetDir(name), "tunnel.json");
271
+ if (!draft && fs.existsSync(tp)) draft = readJson(tp).url;
272
+ if (!draft || /localhost|127\.0\.0\.1/.test(draft)) {
273
+ console.error("need a PUBLIC draft url — a remote human opens it. Start a verified tunnel first:\n node harness/tunnel.js " + name + " [port] (records targets/" + name + "/tunnel.json, used as the default)\nor pass --draft <url> explicitly.\n(no PingHumans account? review locally instead: node harness/human-qa.js file " + name + " --local)");
274
+ process.exit(1);
275
+ }
276
+ }
277
+ const spec = buildSpec(name, draft || "local review — served by ppk serve at /__review", opt("--region"), opt("--changelog"));
278
+ if (cmd === "template") { console.log(JSON.stringify(spec, null, 2)); return; }
279
+ if (local) {
280
+ const pingId = "local-" + require("crypto").randomBytes(6).toString("hex");
281
+ const hq = loadHq(name);
282
+ hq.rounds.push({ ping_id: pingId, provider: "local", draft_url: null, region: spec.title, approve_verdicts: spec.approve_verdicts, filed_at: new Date().toISOString(), spec: { title: spec.title, instructions: spec.instructions, steps: spec.steps, verdict_options: spec.verdict_options }, raw_response: null, last: null, checked_at: null });
283
+ saveHq(name, hq);
284
+ console.log(`✓ filed round ${hq.rounds.length} (LOCAL review) — ${pingId}\n approve verdict: "${spec.approve_verdicts[0]}"\n review it in a browser: http://localhost:8080/__review (ppk serve ${name} must be running)\n the reviewer pins what looks wrong and MUST pick a verdict button; then:\n node harness/human-qa.js verify ${name}\n ⚠ local verdicts are operator-trusted (recorded as provider:"local"); agents never open or submit /__review themselves.`);
285
+ return;
286
+ }
287
+ // A test filed against a dead/wrong tunnel burns the whole human round (hn round 1).
288
+ // Re-verify AT FILE TIME: reachable is required; byte-identical to clone/index.html is
289
+ // expected for a standalone clone (warn-only — a component served by an app dev server
290
+ // legitimately differs).
291
+ const idx = path.join(targetDir(name), "clone", "index.html");
292
+ if (fs.existsSync(idx)) {
293
+ const v = await require("./tunnel.js").verifyServes(draft, idx);
294
+ if (!v.ok && /unreachable|HTTP \d+/.test(v.reason)) { console.error(`❌ refusing to file — draft url is not serving: ${v.reason}`); process.exit(1); }
295
+ if (!v.ok) console.error(`⚠ ${v.reason} — filing anyway (expected only when the draft is not the standalone clone)`);
296
+ if (!/pinghumans\.com\/qa-toolbar\.js/.test(fs.readFileSync(idx, "utf8"))) console.error("⚠ clone/index.html has no PingHumans qa-toolbar <script> — workers can still review, but pinned comments/align data will be limited (capture-build --qa-toolbar adds it)");
297
+ }
298
+ const { approve_verdicts, ...toolArgs } = spec;
299
+ const r = await rpc("request_human_test", toolArgs);
300
+ if (!r.ping_id) throw new Error("filing returned no ping_id");
301
+ const round = pushRound(name, r.ping_id, spec, approve_verdicts);
302
+ console.log(`✓ filed round ${round} — ping ${r.ping_id}\n approve verdict: "${approve_verdicts[0]}"\n gate: node harness/workflow.js gate ${name} human (or: node harness/human-qa.js verify ${name})\n wake on result: npx cpyany wait ${r.ping_id}`);
303
+ return;
304
+ }
305
+
306
+ if (cmd === "record") {
307
+ if (!extra || !/^[0-9a-f-]{36}$/i.test(extra)) { console.error("usage: human-qa.js record <name> <ping_id> [--approve \"Verdict A,Verdict B\"]"); process.exit(2); }
308
+ const approve = (opt("--approve") || "").split(",").map((s) => s.trim()).filter(Boolean);
309
+ if (!approve.length) { console.error('record needs --approve "…" — the verdict string(s) that count as approval for the test you filed'); process.exit(2); }
310
+ const round = pushRound(name, extra, null, approve);
311
+ console.log(`✓ recorded round ${round} — ping ${extra} (approve: ${approve.join(" | ")})`);
312
+ return;
313
+ }
314
+
315
+ if (cmd === "verify") {
316
+ const hq = loadHq(name);
317
+ if (!hq.rounds.length) { console.error(`no human QA round recorded — file one: node harness/human-qa.js file ${name} --draft <public-url>`); process.exit(1); }
318
+ const round = hq.rounds[hq.rounds.length - 1];
319
+ // Local rounds: the review page's submission (written by serve.js's /__review/submit)
320
+ // IS the result — same shape the API returns, same classification below, and the
321
+ // provider travels into the receipt so a local verdict is never mistaken for an
322
+ // independent one.
323
+ const sc = round.provider === "local"
324
+ ? (round.raw_response || { status: "pending", n_received: 0, n_target: 1, responses: [] })
325
+ : await rpc("get_test_results", { ping_id: round.ping_id });
326
+ // Real response schema (verified empirically): the verdict pick is `choice`, prose is
327
+ // `free_text`; older/other shapes may use `verdict`/`notes` — accept both.
328
+ round.last = { status: sc.status, n_received: sc.n_received, n_target: sc.n_target, responses: (sc.responses || []).map((r) => ({ verdict: r.choice != null ? r.choice : (r.verdict != null ? r.verdict : null), notes: r.free_text || r.notes || r.comment || null })) };
329
+ round.checked_at = new Date().toISOString();
330
+ saveHq(name, hq);
331
+ const n = hq.rounds.length;
332
+ const resp = round.last.responses;
333
+ if (!resp.length) {
334
+ console.error(sc.status === "expired" ? `round ${n} EXPIRED unanswered (ping ${round.ping_id}) — refile` : `round ${n} pending — ${sc.n_received}/${sc.n_target} responses (ping ${round.ping_id})`);
335
+ process.exit(1);
336
+ }
337
+ // A response with NO verdict pick (comment-only) can never pass: inferring approval
338
+ // from prose is the exact hole the gates exist to close — "a pass is an explicit
339
+ // machine-checkable fact" applies to humans too. Surface what they wrote instead.
340
+ const unpicked = resp.filter((r) => r.verdict == null);
341
+ if (unpicked.length) {
342
+ console.error(`round ${n} has ${unpicked.length} response(s) with NO verdict pick — comments alone can't pass the gate${unpicked[0].notes ? `; reviewer wrote: "${unpicked[0].notes}"` : ""}\nask the reviewer to pick a verdict option, or refile: node harness/human-qa.js file ${name} --draft <url>`);
343
+ process.exit(1);
344
+ }
345
+ const rejected = resp.filter((r) => !round.approve_verdicts.includes(r.verdict));
346
+ if (rejected.length) {
347
+ console.error(`round ${n} NOT approved — ${rejected.map((r) => `"${r.verdict}"${r.notes ? ` — ${r.notes}` : ""}`).join("; ")}\nfix the flags (PLAYBOOK Phase 6: --inspect the flagged marks), redeploy, then refile: node harness/human-qa.js file ${name} --draft <url>`);
348
+ process.exit(1);
349
+ }
350
+ console.log(`round ${n} approved by ${resp.length} human(s): "${resp[0].verdict}" (${round.provider === "local" ? "LOCAL review — operator-trusted, " : ""}ping ${round.ping_id})`);
351
+ return;
352
+ }
353
+
354
+ if (cmd === "poll" || cmd === "poll-result") {
355
+ const hq = loadHq(name);
356
+ hq.polls = hq.polls || [];
357
+ // shared printer: exit 0 once ≥1 answer exists, 1 while pending/expired
358
+ const report = (sc, entry) => {
359
+ entry.last = { status: sc.status, n_received: sc.n_received, responses: (sc.responses || []).map((r) => ({ choice: r.choice != null ? r.choice : null, text: r.free_text || r.text || null })) };
360
+ entry.checked_at = new Date().toISOString();
361
+ saveHq(name, hq);
362
+ const resp = entry.last.responses;
363
+ if (resp.length) {
364
+ console.log(`poll answered (${resp.length}):` + resp.map((r) => `\n ${r.choice != null ? `[${r.choice}] ` : ""}${r.text || ""}`).join(""));
365
+ return 0;
366
+ }
367
+ console.error(`poll ${entry.ping_id} ${sc.status || "pending"} — 0 answers yet; re-check (free): node harness/human-qa.js poll-result ${name} ${entry.ping_id}`);
368
+ return 1;
369
+ };
370
+ if (cmd === "poll-result") {
371
+ if (!extra) { console.error("usage: human-qa.js poll-result <name> <ping_id>"); process.exit(2); }
372
+ const entry = hq.polls.find((p) => p.ping_id === extra) || (() => { const e = { ping_id: extra, question: null, asked_at: null }; hq.polls.push(e); return e; })();
373
+ process.exit(report(await rpc("get_ping", { ping_id: extra }), entry));
374
+ }
375
+ const question = extra;
376
+ if (!question) { console.error('usage: human-qa.js poll <name> "question" [--choices "A,B,C"]'); process.exit(2); }
377
+ // A comparison-shaped question ("does the clone match the real page?") through the
378
+ // text-poll channel strips the reviewer of the side-by-side compare UI — flagged by
379
+ // the reviewer TWICE on the iphone17 run. Polls may reference ONE side; a question
380
+ // naming both sides is a comparison and belongs in a filed test (file --region "…"
381
+ // scopes a quick compare round). Refusal, not advice — prose rules kept being skipped.
382
+ const namesTheClone = /\b(clone|draft|replica|copy|ours?|our (page|site|version))\b/i.test(question);
383
+ const namesTheLive = /\b(original|real|live|reference|apple'?s?|actual)\b[^.]{0,40}\b(page|site|version|one)\b|\bthe (original|real thing)\b/i.test(question);
384
+ if (namesTheClone && namesTheLive && !args.includes("--allow-comparison")) {
385
+ console.error(`❌ refusing to poll — this question names BOTH the clone and the live page, which makes it a COMPARISON: the reviewer needs the side-by-side compare UI, not a text question.\n file a scoped compare round instead: node harness/human-qa.js file ${name} --region "<the section>"\n (or, if this genuinely isn't a comparison, re-run with --allow-comparison)`);
386
+ process.exit(1);
387
+ }
388
+ // context the human needs to answer a visual question: the verified draft + original
389
+ let ctx = "";
390
+ try { ctx += `\nDraft: ${readJson(path.join(targetDir(name), "tunnel.json")).url}`; } catch (e) {}
391
+ try { ctx += `\nOriginal: ${readJson(path.join(targetDir(name), "target.json")).url}`; } catch (e) {}
392
+ const choices = (opt("--choices") || "").split(",").map((s) => s.trim()).filter(Boolean);
393
+ const args_ = { question: question + ctx, n_target: 1, deadline_seconds: 3600 };
394
+ if (choices.length) args_.choices = choices;
395
+ const sc = await rpc("ping_humans", args_, 320_000); // server blocks up to ~300s while a human answers
396
+ const entry = { ping_id: sc.ping_id || null, question, asked_at: new Date().toISOString(), last: null, checked_at: null };
397
+ hq.polls.push(entry);
398
+ process.exit(report(sc, entry));
399
+ }
400
+
401
+ console.error(`unknown command "${cmd}" — template | file | record | verify | poll | poll-result`);
402
+ process.exit(2);
403
+ }
404
+
405
+ if (require.main === module) main().catch((e) => { console.error(`human-qa: ${e.message}`); process.exit(1); });
406
+ module.exports = { buildSpec, resolveToken };
@@ -0,0 +1,56 @@
1
+ // harness/merge-snapshot-selftest.js — guards the incremental-capture merge
2
+ // (tools/merge-snapshot.js) and its honesty contract with the done gate. Offline.
3
+ // - partial elements overwrite; untouched elements carry over; merged stamp accumulates keys
4
+ // - width / compat-mode mismatch refused (would poison x-positions / #18)
5
+ // - the done gate refuses a merged (stamped) snapshot; a full re-capture (no stamp) clears it
6
+ // Run: node harness/merge-snapshot-selftest.js (regression.js runs it too)
7
+ "use strict";
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const cp = require("child_process");
11
+ const { mergeSnapshots } = require("../tools/merge-snapshot.js");
12
+
13
+ const KIT = path.resolve(__dirname, "..");
14
+ let failed = 0;
15
+ const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
16
+
17
+ console.log("merge-snapshot-selftest — incremental capture merge + done-gate honesty");
18
+
19
+ // ── merge semantics (pure) ────────────────────────────────────────────────────
20
+ const snap = (els, width, mode) => ({ viewport: { width }, mode: mode || "CSS1Compat", elements: els });
21
+ {
22
+ const full = snap({ a: { present: true, v: 1 }, b: { present: true, v: 1 }, c: { present: true, v: 1 } }, 1512);
23
+ const r = mergeSnapshots(full, snap({ b: { present: true, v: 2 } }, 1512));
24
+ ok(!r.error && full.elements.b.v === 2 && full.elements.a.v === 1 && r.carried === 2, "partial overwrites its keys; others carry over");
25
+ ok(full.merged && full.merged.keys.join() === "b", "merge stamps merged:{at,keys}");
26
+ mergeSnapshots(full, snap({ c: { present: true, v: 2 } }, 1512));
27
+ ok(full.merged.keys.sort().join() === "b,c", "successive merges ACCUMULATE stamped keys");
28
+ ok(mergeSnapshots(full, snap({ a: {} }, 1280)).error.includes("width"), "viewport-width mismatch refused");
29
+ ok(mergeSnapshots(full, snap({ a: {} }, 1512, "BackCompat")).error.includes("modes differ"), "compat-mode mismatch refused (#18)");
30
+ ok(mergeSnapshots(full, snap({}, 1512)).error.includes("no elements"), "empty partial refused");
31
+ }
32
+
33
+ // ── done-gate refusal of merged snapshots (through the real workflow) ────────
34
+ {
35
+ const run = (args) => { const r = cp.spawnSync(process.execPath, [path.join(KIT, "harness", "workflow.js"), ...args], { encoding: "utf8", cwd: KIT }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
36
+ const NAME = "mergeselftest_" + process.pid;
37
+ const dir = path.join(KIT, "targets", NAME);
38
+ process.on("exit", () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} });
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ fs.mkdirSync(path.join(dir, "clone"), { recursive: true });
41
+ fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name: NAME, url: "https://example.com/", width: 1512 }));
42
+ run(["init", NAME]);
43
+ const el = { present: true, rect: { x: 0, y: 0, w: 10, h: 10, top: 0, right: 10, bottom: 10, fromRight: 0 } };
44
+ const clean = snap({ logo: el }, 1512);
45
+ fs.writeFileSync(path.join(dir, "clone.json"), JSON.stringify(clean));
46
+ const stamped = { ...clean, merged: { at: "2026-07-05T00:00:00.000Z", keys: ["logo"] } };
47
+ fs.writeFileSync(path.join(dir, "live.json"), JSON.stringify(stamped));
48
+ const refused = run(["gate", NAME, "done"]);
49
+ ok(refused.code === 1 && /MERGED iteration snapshot/.test(refused.out) && /live\.json/.test(refused.out), "done gate refuses a merged-stamped snapshot, naming the file");
50
+ fs.writeFileSync(path.join(dir, "live.json"), JSON.stringify(clean));
51
+ const after = run(["gate", NAME, "done"]);
52
+ ok(!/MERGED iteration snapshot/.test(after.out), "a full re-capture (stamp gone) clears the refusal (done then blocks on ordinary pending phases only)");
53
+ }
54
+
55
+ console.log(failed ? `\n❌ merge-snapshot-selftest: ${failed} assertion(s) failed.` : "\n✓ merge-snapshot-selftest: all assertions pass.");
56
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,101 @@
1
+ // harness/new-target.js <name> <url> [width=1728] — scaffold a fresh clone workspace.
2
+ //
3
+ // A "target" is a disposable instance under targets/<name>/. The KIT (tools + the .md
4
+ // instructions) is the product; targets are where you exercise it on a real site. The
5
+ // scaffold is plain HTML/CSS (no build step) so the pixel loop is instant: edit
6
+ // clone/ → refresh → capture → `node harness/score.js <name>`.
7
+ const fs = require("fs"), path = require("path");
8
+ const [, , name, url, widthArg] = process.argv;
9
+ if (!name || !url) { console.error("usage: node harness/new-target.js <name> <url> [width=1728]"); process.exit(1); }
10
+ const width = +(widthArg || 1728);
11
+ // targets/ live in the USER's current directory (WORK), not inside the installed kit (PKG).
12
+ const WORK = process.cwd();
13
+ const dir = path.join(WORK, "targets", name);
14
+ if (fs.existsSync(dir)) { console.error(`targets/${name} already exists — delete it first or pick another name`); process.exit(1); }
15
+ fs.mkdirSync(path.join(dir, "clone", "assets"), { recursive: true });
16
+
17
+ fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name, url, width }, null, 2) + "\n");
18
+
19
+ // Seed the ENFORCED workflow state machine (the kit's `gjc`). From here on the clone must
20
+ // pass each phase gate IN ORDER — nothing can claim "pixel-perfect" without the receipts.
21
+ require("./workflow.js").initWorkflow(name, url, width);
22
+
23
+ fs.writeFileSync(path.join(dir, "clone", "index.html"), `<!doctype html>
24
+ <!-- CHECK LIVE'S DOCTYPE FIRST: if the live page ships no doctype it renders in QUIRKS
25
+ mode (document.compatMode === "BackCompat") and this standards-mode doctype will
26
+ shift table-cell line boxes with every computed style identical — delete it to
27
+ match (LEARNINGS #18). The capture records \`mode\` and the gate fails a mismatch. -->
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8" />
31
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
32
+ <title>${name} — header clone</title>
33
+ <link rel="stylesheet" href="styles.css" />
34
+ </head>
35
+ <body>
36
+ <!-- DEFAULT BUILD STRATEGY: capture, don't reconstruct (LEARNINGS #19). Capture the
37
+ live page's post-hydration DOM (pxSendDom → dom.html) and run
38
+ \`ppk capture-build <name>\` — it REPLACES this whole scaffold with the captured
39
+ markup + self-hosted CSS/fonts, inheriting live's doctype and drawing techniques
40
+ by construction. Hand-rebuild here (to MEASUREMENTS, never guesses) only when the
41
+ deliverable is a component in your own stack. See ../../PLAYBOOK.md Phase 4. -->
42
+ <header class="site-header">
43
+ <!-- TODO: build to spec -->
44
+ </header>
45
+
46
+ <!-- Capture (with \`node tools/sink.js\` running on :7799):
47
+ const s = await fetch('/tools/browser-capture.js').then(r => r.text()); (0, eval)(s);
48
+ window.pxRegion = { maxY: 135 };
49
+ window.pxTargets = [ ["logo", () => document.querySelector('.logo'), false], ... ];
50
+ await pxSend('http://localhost:7799/clone.json'); -->
51
+ </body>
52
+ </html>
53
+ `);
54
+
55
+ fs.writeFileSync(path.join(dir, "clone", "styles.css"), `/* ${name} clone — build to measured values */
56
+ * { box-sizing: border-box; }
57
+ html, body { margin: 0; padding: 0; }
58
+ body {
59
+ /* Learned default (LEARNINGS #13): most design systems ship antialiased smoothing —
60
+ matching it up front avoids a "looks thicker" miss the gate would otherwise flag. */
61
+ -webkit-font-smoothing: antialiased;
62
+ -moz-osx-font-smoothing: grayscale;
63
+ }
64
+ `);
65
+
66
+ fs.writeFileSync(path.join(dir, "scores.jsonl"), "");
67
+ fs.writeFileSync(path.join(dir, "NOTES.md"), `# ${name} — ${url} @ ${width}px
68
+
69
+ ## Iteration log
70
+ Record each loop: what a human/you flagged, whether the **gate** caught it, and the fix.
71
+ A flag the gate MISSED is the valuable one — it becomes a tool check + fixture (see DEVELOP.md).
72
+
73
+ | round | flagged | gate caught it? | fix | kit change (tool/fixture/instruction) |
74
+ |-------|---------|-----------------|-----|----------------------------------------|
75
+ | | | | | |
76
+
77
+ ## Site-specific findings
78
+ (structure quirks, faux-bold, etc. — generalize the durable ones into LEARNINGS.md)
79
+ `);
80
+
81
+ // Print next steps that are RUNNABLE in the invoking context: `ppk …` when launched via the
82
+ // installed ppk entrypoint (PPK_ENTRY set by its delegate), `node harness/…` when someone in
83
+ // the repo ran this script directly (they have no ppk on PATH).
84
+ const viaPpk = process.env.PPK_ENTRY === "1";
85
+ const c = (ppkForm, nodeForm) => (viaPpk ? ppkForm : nodeForm);
86
+ console.log(`created targets/${name}/ (url=${url}, width=${width})
87
+
88
+ next:
89
+ ${c("ppk sink &", "node tools/sink.js &")} # snapshot receiver on :7799
90
+ ${c(`ppk serve ${name}`, `node harness/serve.js ${name}`)} # serves clone + /tools on :8080
91
+ # 1. open the live site at ${width}px, measure it (RUNBOOK), POST live.json into targets/${name}/
92
+ # 2. build clone/index.html — DEFAULT: capture it (RUNBOOK "Build by capture"):
93
+ # live tab: await pxSendDom('http://localhost:7799/dom.html')
94
+ # then: ${c(`ppk capture-build ${name}`, `node harness/capture-build.js ${name}`)}
95
+ # (hand-rebuild to measurements only when the deliverable is a component in your stack)
96
+ # 3. capture clone.json, then:
97
+ ${c(`ppk score ${name}`, `node harness/score.js ${name}`)} # scores this run vs the last (is it better?)
98
+
99
+ the enforced workflow tracks your phases (target → assets → measure → build → visual → coverage → strict → done):
100
+ ${c(`ppk status ${name}`, `node harness/workflow.js status ${name}`)} # what's done, what's next, whether its gate passes
101
+ ${c(`ppk advance ${name} <phase>`, `node harness/workflow.js advance ${name} <phase>`)} # record a phase — refuses unless its gate exits 0`);
@@ -0,0 +1,50 @@
1
+ // harness/regression.js — the kit's guardrail. Runs the gate-guard (tools/selftest.js)
2
+ // plus every harness/fixtures/*.js. Each class of miss found while cloning a real site
3
+ // becomes a fixture here, so it can never silently come back (that's the whole point:
4
+ // a lesson goes into the TOOL + a fixture, not into a human checklist). Exit nonzero if
5
+ // any fail — wire this into CI or run it before shipping a pixel-diff.js change.
6
+ const { execFileSync } = require("child_process");
7
+ const fs = require("fs"), path = require("path");
8
+ const KIT = path.resolve(__dirname, "..");
9
+
10
+ const tests = [path.join(KIT, "tools", "selftest.js"), path.join(KIT, "tools", "cli-selftest.js"), path.join(KIT, "harness", "workflow-selftest.js"), path.join(KIT, "harness", "capture-build-selftest.js"), path.join(KIT, "harness", "human-qa-selftest.js"), path.join(KIT, "harness", "tunnel-selftest.js"), path.join(KIT, "harness", "behavior-selftest.js"), path.join(KIT, "harness", "merge-snapshot-selftest.js"), path.join(KIT, "harness", "doctor-selftest.js"), path.join(KIT, "harness", "setup-selftest.js")];
11
+ const fixDir = path.join(__dirname, "fixtures");
12
+ if (fs.existsSync(fixDir))
13
+ for (const f of fs.readdirSync(fixDir).filter((f) => f.endsWith(".js")).sort()) tests.push(path.join(fixDir, f));
14
+ // the detection-power battery (absolute mode) — asserts the current gate catches every
15
+ // known defect class and flags no control. A broader guard than the per-class fixtures.
16
+ const bench = path.join(__dirname, "benchmarks", "detection-power.js");
17
+ if (fs.existsSync(bench)) tests.push(bench);
18
+
19
+ let failed = 0;
20
+
21
+ // SYNTAX GUARD — a kit script that doesn't parse is a broken kit even when no test
22
+ // imports it. Paid for: a template-literal typo in new-target.js shipped while this
23
+ // suite was green, because the scaffold is only ever run by users, never by a test
24
+ // (caught by an agent mid-clone when `ppk new` wouldn't start). `node --check` every
25
+ // kit script so the whole surface at least parses.
26
+ const syntaxFiles = [path.join(KIT, "bin", "ppk")];
27
+ for (const root of ["tools", "harness"].map((d) => path.join(KIT, d))) {
28
+ const walk = (d) => {
29
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
30
+ const fp = path.join(d, e.name);
31
+ if (e.isDirectory()) walk(fp);
32
+ else if (e.name.endsWith(".js")) syntaxFiles.push(fp);
33
+ }
34
+ };
35
+ if (fs.existsSync(root)) walk(root);
36
+ }
37
+ let syntaxFailed = 0;
38
+ for (const f of syntaxFiles) {
39
+ try { execFileSync("node", ["--check", f], { stdio: "pipe" }); }
40
+ catch (e) { syntaxFailed++; failed++; console.log(`✗ syntax ${path.relative(KIT, f)}`); process.stdout.write((e.stderr || "").toString()); }
41
+ }
42
+ if (!syntaxFailed) console.log(`✓ syntax — all ${syntaxFiles.length} kit scripts parse (node --check)`);
43
+
44
+ for (const t of tests) {
45
+ const label = path.relative(KIT, t);
46
+ try { execFileSync("node", [t], { stdio: "pipe" }); console.log(`✓ ${label}`); }
47
+ catch (e) { failed++; console.log(`✗ ${label}`); process.stdout.write((e.stdout || "").toString() + (e.stderr || "").toString()); }
48
+ }
49
+ console.log(failed ? `\n❌ regression: ${failed} of ${tests.length} file(s) failed.` : `\n✓ regression: all ${tests.length} file(s) pass — the gate still catches every known class of miss.`);
50
+ process.exit(failed ? 1 : 0);