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
package/WORKFLOW.md ADDED
@@ -0,0 +1,257 @@
1
+ # WORKFLOW — the enforced pixel-perfect pipeline (`ppk`)
2
+
3
+ The PLAYBOOK describes the method. This describes the **enforcement**: a hard-gated state
4
+ machine that makes an agent (or you) walk the phases **in order**, and **refuses to let a
5
+ clone claim "pixel-perfect" until every gate has exited 0.** The method was prose the agent
6
+ was *asked* to follow; this is a machine that *won't advance* until the objective condition
7
+ is met.
8
+
9
+ It's modeled on [gajae-code](https://github.com/Yeachan-Heo/gajae-code)'s `gjc` workflow
10
+ (`deep-interview → ralplan → ultragoal`), reimplemented natively with zero dependencies —
11
+ see **Benchmark vs gajae-code** below for what we borrowed and what we deliberately didn't.
12
+
13
+ > The kit's one rule holds here too: **a phase is done because its gate exited 0, never
14
+ > because prose says so.**
15
+
16
+ ---
17
+
18
+ ## The phases (in order)
19
+
20
+ | # | phase | gate — the objective condition that must hold | kind |
21
+ |---|------------|-----------------------------------------------|------|
22
+ | 1 | `target` | `target.json` has a url + a fixed width | machine |
23
+ | 2 | `assets` | any shipped `.woff2` has real `wOF2` magic bytes; icons/logo attested captured, not redrawn | attested |
24
+ | 3 | `measure` | `live.json` exists, is valid, has elements, measured **at the fixed width** | machine |
25
+ | 4 | `build` | `clone/index.html` built (no scaffold TODO) + a valid `clone.json` captured | machine |
26
+ | 5 | `visual` | `pixel-diff --visual` is green (0 pixel-determining fails, matching widths) | machine |
27
+ | 6 | `coverage` | every painted leaf in `coverage.json` has a measured target on **both** pages | machine |
28
+ | 7 | `strict` | strict deltas are **0**, or every *structural* one is documented in `deviations.json` — a **paint** delta (one that also fails `--visual`) can never be documented away | machine |
29
+ | 8 | `behavior` | every JS-driven dynamic discovered on the LIVE page (animation, rotation, reveal, marquee, counter, hover-mounted content) either reproduces on the clone within a documented tolerance, or is explicitly excused in `behavior-deviations.json` — an empty/absent discovery pass is never a free pass (see "The `behavior` phase" below) | machine |
30
+ | 9 | `human` | the **latest** PingHumans side-by-side round is human-approved — the verdict is re-fetched from the API on every check (a cached approval is never trusted); a rejection surfaces the human's flags as the fix list | machine |
31
+ | 10 | `done` | **default-FAIL final verification:** every earlier gate is **re-run** against the current artifacts (a recorded pass is not trusted — it must still hold), in order, and no phase may be forced | machine |
32
+
33
+ **machine** = the gate fully proves it (exit 0 is a fact). **attested** = it can't be fully
34
+ machine-checked ("this icon is the real one, not hand-drawn"), so the gate does what light
35
+ checks it can and then requires an `--evidence` string, recorded in the audit log and flagged
36
+ `attested` so nobody mistakes an assertion for a proof.
37
+
38
+ The gates are **build-strategy agnostic** — they verify the artifacts, not how you made
39
+ them. The **default** way to satisfy `build` is `ppk capture-build <name>`: build the clone
40
+ *from the captured live DOM* (self-hosted CSS/fonts, doctype preserved byte-for-byte), which
41
+ eliminates the technique-mismatch defect class the gates are structurally blind to
42
+ (LEARNINGS #19). Hand-rebuild only when the deliverable is a component in your own stack.
43
+
44
+ ---
45
+
46
+ ## Commands
47
+
48
+ ```sh
49
+ node harness/workflow.js status <name> # phase table + the next required action
50
+ node harness/workflow.js gate <name> <phase> # run ONE gate read-only (exit 0/1) — no state change
51
+ node harness/workflow.js advance <name> <phase> [--evidence "…"] [--force]
52
+ node harness/workflow.js ledger <name> # the audit trail (receipts)
53
+ node harness/capture-build.js <name> [domFile] # the default build step: clone FROM the captured DOM
54
+ node harness/human-qa.js file <name> --draft <public-url> [--region "the header"] # file the human round
55
+ ```
56
+
57
+ ### The `behavior` phase (tools/behavior-capture.js + the gate in harness/workflow.js)
58
+ Statics are proven by `visual`/`coverage`/`strict` before this phase runs; `behavior` proves
59
+ the clone's JS-driven dynamics — animations, rotations, reveals, marquees, counters,
60
+ hover-mounted content — reproduce the live page's actual measured behavior, not just its
61
+ static end-state. Method ported verbatim from `lovable_dupe_html/CLONE_PLAYBOOK.md` §8/§8a —
62
+ see `PLAYBOOK.md`'s behavior section for the full technique writeup. In short:
63
+
64
+ 1. **Discover on LIVE** with `tools/behavior-capture.js` (`pxBehaviorDiscover()` /
65
+ `pxBehaviorSend(url)` / `pxBehaviorStash()` — same injection + delivery rules as
66
+ `browser-capture.js`, RUNBOOK.md "Golden rules"): a static pass greps `@keyframes` +
67
+ class/data-attribute markers for CANDIDATES, then a dynamic differential pass (a
68
+ `MutationObserver` + per-element opacity/transform/filter snapshots across a scripted
69
+ scroll sweep and hover probes) confirms which candidates actually fire and MEASURES them
70
+ (marquee px/sec, reveal end-states, hover-mount deltas). Save the result as
71
+ `targets/<name>/behaviors-live.json`.
72
+ 2. **Reproduce** in one vanilla `clone/fixes.js` (no framework — `harness/capture-build.js
73
+ --fixes` wires `<script src="fixes.js" defer>` before `</body>` and scaffolds a starter
74
+ file the first time; it never overwrites one you've already written). Each behavior gets
75
+ its own guarded `try` block, per the playbook.
76
+ 3. **Discover on the CLONE** the same way (with `fixes.js` loaded, captured AFTER the same
77
+ settle procedure as live — scroll sweep + dwell — so end-states match) →
78
+ `targets/<name>/behaviors-clone.json`.
79
+ 4. The gate (`harness/workflow.js`, phase `behavior`) compares every live behavior against
80
+ the clone's inventory by KEY (see "Behavior keys" below) and MEASURED value, within the
81
+ tolerances below. A behavior that's genuinely irreproducible statically (e.g. a WebGL
82
+ generative background) is documented in `targets/<name>/behavior-deviations.json` —
83
+ `{ "<key>": { "reason": "WebGL generative — irreproducible statically" } }` — the same
84
+ escape hatch `strict`'s `deviations.json` uses, with the same rule: a behavior that ALSO
85
+ changes what `--visual` would see (a frozen, still-mid-transition end state) is a paint
86
+ delta in spirit and should be fixed, not excused.
87
+
88
+ **"Discovery ran" is itself evidenced**, not inferred from an empty inventory (a
89
+ `behaviors-live.json` with `behaviors: {}` and no `discovery` metadata is indistinguishable
90
+ from a script that silently no-oped, so the gate refuses it as a paint-over). Every capture
91
+ records its own discovery pass metadata — `elementsScanned`, `scrollSweep: {from,to,steps}`,
92
+ `observeMs`, `keyframesFound`, which hover triggers/marquee selectors were probed — and a
93
+ page with genuinely zero dynamic behaviors passes with that metadata cited as the reason.
94
+
95
+ **Behavior keys.** Each discovered behavior gets a stable string key so live and clone
96
+ inventories compare by identity, not by set overlap: `<prefix>:<descriptor>` where prefix is
97
+ `reveal` (scroll/class-toggle reconciled against a static candidate), `mutation` (an observed
98
+ change with no static marker — pure-JS timed rotations), `marquee` (an explicitly-probed
99
+ translating belt), or `hover` (an explicitly-probed hover-mounted panel/menu); descriptor
100
+ prefers `id`/`data-testid`/`aria-label`, falling back to `tag.class1.class2`, falling back to
101
+ a shallow structural path. Stability matters more than prettiness — the gate does an exact
102
+ key match between live and clone.
103
+
104
+ **Tolerances** (documented here so a failure message is self-explanatory, the same way
105
+ `--visual`'s 0.5px is):
106
+ - **speed (`pxPerSec`, marquees/scroll-linked motion): ±15% relative.** Two independent
107
+ 1-second `transform` samples carry timer/paint-scheduling jitter (~3–8% observed on a quiet
108
+ machine); 15% absorbs that while a genuinely wrong speed — the common miss is copying the
109
+ wrong keyframe duration or belt width, landing at exactly 2x or 0.5x — still fails by a wide
110
+ margin.
111
+ - **duration (`durationMs`): ±25% relative, 150ms floor.** Wall-clock durations are measured
112
+ across a network+paint round-trip on BOTH captures (more jitter than a same-machine
113
+ transform sample); the floor keeps that noise from flagging very short (<600ms)
114
+ transitions, where 25% would be under a frame's worth of time.
115
+ - **opacity: ±0.05 absolute.** Absorbs float-rounding differences across engines; a
116
+ fully-revealed `1.0` reproduced as a stuck `0.92` is still a real, visible miss.
117
+ - **transform: matrix-aware compare — ±0.5px on the translation components, ±0.001 on the
118
+ linear part; non-matrix strings exact.** Computed `transform` normalizes to
119
+ `none|matrix(...)`, and matrix *translation* inherits layout subpixel rounding — exact
120
+ string equality would flake on jitter `--visual` itself tolerates, while a genuinely wrong
121
+ end-state (`-2125px` reproduced as `-1000px`) still fails by three orders of magnitude.
122
+ - **filter / trigger: exact string match.** Not measurements with sampling noise; `trigger`
123
+ (load/scroll/hover/mutation) records the reproduction TECHNIQUE, which the playbook
124
+ requires to match, not just land on the right pixels by luck.
125
+ - **`observed-mutation` behaviors (interval rotations) compare by KEY PRESENCE + trigger
126
+ only.** A continuously-mutating element's snapshot is whatever frame it was on — end-state
127
+ floats from two independent captures can never agree, so comparing them would be fiction
128
+ dressed as measurement. The contract is: the clone rotates too (key present, same trigger);
129
+ its per-frame states are for the human round to judge.
130
+
131
+ ### The `human` phase (harness/human-qa.js)
132
+ Two providers, one contract (an explicit human verdict + pins, machine-checkably
133
+ recorded): **marketplace** (PingHumans, the default — independent reviewers over a
134
+ verified public tunnel) and **local** (`file --local` — the kit's own serve hosts the
135
+ review page at `/__review`: the clone in a same-origin iframe with click-to-pin, the
136
+ same generated step list, the same mandatory verdict buttons; no account, no tunnel).
137
+ The trust model is explicit and travels in the receipts: local rounds record
138
+ `provider:"local"` and verify prints "operator-trusted" — an agent with browser control
139
+ *could* forge a local submission, which is exactly why marketplace review exists and why
140
+ agents are forbidden from ever opening or submitting `/__review` themselves.
141
+
142
+ The gates prove what the tool measures; a human proves the measured set is what a person
143
+ actually *sees* (LEARNINGS "the gate vs your eyes"). The verdict is machine-checkable, so
144
+ this is a **machine** gate: `human-qa.js verify` re-fetches the latest round's verdict from
145
+ the PingHumans API (same authenticated JSON-RPC transport as `npx cpyany wait`; the
146
+ designer's existing cpyany login is reused) and exits 0 only on approval. The generated
147
+ test is **scope-pinned** — the reviewer judges only the cloned region, per-leaf compare
148
+ steps come from `coverage.json`, and JS behavior is marked *informational* in the human
149
+ template (the `behavior` phase gate is what PROVES dynamics now; the informational step is a
150
+ human backstop for taste/feel and anything intentionally excused in `behavior-deviations.json`,
151
+ not a substitute for the gate) — encoding the lessons stripe's 8 unconverged rounds paid for.
152
+ A rejection prints the human's notes as the fix list (PLAYBOOK Phase 6), and after fixing you
153
+ **refile**; verify always judges the latest round, so a stale approval can't carry a
154
+ regressed clone. Rounds and verdicts live in `targets/<name>/human-qa.json` — the receipt
155
+ pins ping_id + verdict content.
156
+
157
+ Or via the shim: `./bin/ppk status <name>` (symlink `bin/ppk` onto your PATH for `ppk status <name>`).
158
+
159
+ `new-target.js` seeds the workflow automatically, so a fresh target starts at phase 1.
160
+
161
+ ### What `advance` enforces
162
+ - **In order.** Advancing phase N is refused while any earlier phase is still pending.
163
+ - **On a passing gate only.** If the gate fails, the advance is refused with the exact reason.
164
+ - **Attestation needs evidence.** An attested phase refuses to advance without `--evidence`
165
+ (and the evidence value must be real text, never a flag).
166
+ - **`--force` is the escape hatch — and it's never silent.** *Any* enforcement it bypasses —
167
+ ordering, missing attestation evidence, or a failing gate — records `forced:true` plus an
168
+ `overrode:[…]` list naming exactly what was skipped. The `done` gate refuses a workflow
169
+ containing any forced phase until it is cleanly re-advanced.
170
+ - **Refusals are receipted too.** A rejected advance (out of order, missing evidence, failing
171
+ gate) appends a `gate:"refused"` line to the ledger, so probing the gates leaves a trace.
172
+
173
+ ### Receipts / audit trail
174
+ Every advance appends one line to `targets/<name>/workflow.jsonl`:
175
+
176
+ ```json
177
+ {"ts":"…","phase":"visual","runId":"a1b2c3d4e5","gate":"pass","forced":false,"overrode":[],"sha256":"…","artifact":"targets/x/clone.json","evidence":null,"reason":"--visual PASS — 128 comparisons, 0 fails"}
178
+ ```
179
+
180
+ The `sha256` pins the exact artifact that was verified — a file's bytes, or for a directory
181
+ artifact every file's relative name **and contents** (so a same-named swap changes the hash).
182
+ The receipt proves *what* passed, not just *that* something did. And because `done` re-runs
183
+ every gate, a receipt going stale (artifact edited after certification) is caught before the
184
+ workflow can complete.
185
+
186
+ ### State corruption & recovery
187
+ `workflow.json` is validated on every load. If it's truncated/corrupt/invalid-shape, every
188
+ command fails with the recovery instruction instead of a stack trace: re-seed with
189
+ `ppk init <name> --force`. A forced re-seed **appends a `reset` receipt to the ledger** (the
190
+ ledger itself is append-only and survives), then each phase re-advances by re-running its
191
+ gate against the artifacts on disk — nothing green can be claimed that isn't re-proven.
192
+
193
+ ---
194
+
195
+ ## Files the workflow reads/writes (per target)
196
+
197
+ - `target.json` — url + fixed width (written by `new-target.js`).
198
+ - `workflow.json` — phase state (status / runId / sha256 / evidence per phase).
199
+ - `workflow.jsonl` — the append-only audit trail.
200
+ - `live.json` / `clone.json` — the measured snapshots (from the RUNBOOK).
201
+ - `coverage.json` — **you create this**: the enumerated painted leaves in the region, e.g.
202
+ `["logo","nav_first","cart_icon",…]`. The coverage gate checks each has a measured target.
203
+ - `deviations.json` — **you create this** to document accepted structural deltas, e.g.
204
+ `{ "nav_first": { "layout.display": "flex vs inline-block — identical pixels" } }`.
205
+ - `behaviors-live.json` / `behaviors-clone.json` — the discovered + measured JS-driven
206
+ dynamics on each page (from `tools/behavior-capture.js`'s `pxBehaviorDiscover()`), each
207
+ including the discovery pass's own metadata (scroll sweep range, observer duration,
208
+ elements scanned) as evidence the pass actually ran.
209
+ - `behavior-deviations.json` — **you create this** to document behaviors that are
210
+ genuinely irreproducible statically, e.g.
211
+ `{ "mutation:div.hero-canvas": { "reason": "WebGL generative — irreproducible statically" } }`.
212
+ - `human-qa.json` — the human QA rounds (ping_id, approve verdicts, latest fetched
213
+ result per round), written by `harness/human-qa.js`.
214
+ - `tunnel.json` — the public draft url (`harness/tunnel.js` records it only after
215
+ byte-verifying it serves `clone/index.html`); `human-qa.js file` uses it as the
216
+ default `--draft` and re-verifies at file time.
217
+ - `sink-tunnel.json` (workspace-level, not per-target) — a public url in front of the
218
+ snapshot sink (`harness/tunnel.js --sink`, verified by the sink's own empty-POST
219
+ signature), so live pages deliver captures with one `pxSend` call even when the
220
+ automation environment blocks page→localhost fetch.
221
+ - **Merged snapshots**: `tools/merge-snapshot.js` folds a partial re-capture into
222
+ `live.json`/`clone.json` for fast fix-loop iteration, stamping `merged:{at,keys}`.
223
+ The `done` gate refuses stamped snapshots — a fix can displace elements outside the
224
+ re-captured subset, so one final full capture is always required.
225
+ - **Micro-polls**: `human-qa.js poll` puts a ~$0.05 single question in front of a human
226
+ mid-round (recorded under `polls` in `human-qa.json`). Advisory only — the `human`
227
+ gate never reads polls; it requires an approving verdict on a full round.
228
+
229
+ ---
230
+
231
+ ## Benchmark vs gajae-code (`gjc`)
232
+
233
+ gajae-code is the closest existing reference for "an enforced coding-agent workflow." We
234
+ studied its skills (`deep-interview`, `ralplan`, `ultragoal`) as a **pattern reference only** —
235
+ no runtime dependency on `gjc`. How the kit's workflow compares:
236
+
237
+ | dimension | gajae-code (`gjc`) | pixel-perfect-kit (`ppk`) |
238
+ |---|---|---|
239
+ | Gated pipeline | `deep-interview → ralplan → ultragoal → execute` | `target → assets → measure → build → visual → coverage → strict → behavior → human → done` |
240
+ | Refuses to advance | ambiguity must drop below a resolved threshold; mutation blocked pre-approval | each phase gate must exit 0; advances refused out-of-order or on a failing gate |
241
+ | Definition of done | checkpoint with `--evidence` + `--quality-gate-json` | a gate command that exits 0 (`--visual` green, coverage closed, strict documented) |
242
+ | Audit trail | `index.jsonl` with `run_id` / `path` / `sha256` per stage | `workflow.jsonl` with `runId` / `artifact` / content-`sha256` per advance — including **refused** advances and forced **resets** |
243
+ | Override | explicit force override, recorded | `--force` records `forced:true` + `overrode:[order\|evidence\|gate]`; `done` refuses forced phases until cleanly re-advanced |
244
+ | State corruption recovery | `gjc state clear --force --mode <skill>` re-seeds scoped state | corrupt `workflow.json` → self-describing error + `ppk init <name> --force` (receipted reset; ledger survives) |
245
+ | Objectivity | mathematical ambiguity score (partly heuristic) | **fully numeric** — the "done" signal is a pixel diff exiting 0, not a heuristic score |
246
+
247
+ **What we borrowed:** the gated-pipeline shape, receipt-based audit trail, and the
248
+ "can't-advance-until-the-gate-passes" discipline.
249
+
250
+ **What we deliberately didn't:** `gjc`'s multi-agent consensus roles (Planner/Architect/Critic)
251
+ and the mathematical ambiguity scorer. This domain has something `gjc`'s general workflow can't:
252
+ a **fully objective** done-signal (the pixel diff). We don't need a panel to argue about whether
253
+ the clone is right — the numbers decide. The one place human/vision judgment stays in the loop
254
+ is *detection* (PLAYBOOK Phase 6), and that already feeds the DEVELOP meta-loop.
255
+
256
+ **Possible future step (not built):** run the kit as a `gjc` skill so it becomes a domain
257
+ plugin on top of gajae-code's harness. Left out on purpose to keep the kit zero-dependency.
package/bin/ppk ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // ppk — thin shim so the enforced workflow reads like a real tool: `ppk status aloyoga`.
3
+ // Delegates to harness/workflow.js. Run from the repo, or symlink onto your PATH.
4
+ require("../harness/workflow.js").main();
@@ -0,0 +1,52 @@
1
+ // harness/adopt.js — `ppk adopt <name> <url> [width]`: register an EXTERNALLY-BUILT
2
+ // draft for the kit's human-review loop.
3
+ //
4
+ // WHY: the human loop (scope-pinned compare rounds, changelogs on refiles, mandatory
5
+ // verdicts, deviations disclosure, the fix-loop discipline) is useful to ANY builder —
6
+ // ditto's capture-to-code output, a Lovable build, a hand-written recreation. Those
7
+ // builders get you most of the way; the loop with a real human closes the gap. Adopt
8
+ // creates just enough target state for `ppk tunnel` + `ppk human <name> …` to run,
9
+ // WITHOUT the pixel pipeline (no workflow gates — there's no captured live.json/clone.json
10
+ // pair to verify; the human's verdict is the whole check).
11
+ //
12
+ // USAGE: ppk adopt <name> <url> [width=1512]
13
+ // then: ppk tunnel <name> --url http://localhost:3000 (your dev server)
14
+ // ppk human <name> file [--region "…"] (the loop begins)
15
+ "use strict";
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+
20
+ const WORK = process.cwd();
21
+
22
+ function main() {
23
+ const [name, url, widthArg] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
24
+ if (!name || !url) { console.error("usage: ppk adopt <name> <url> [width=1512]"); process.exit(2); }
25
+ let parsed;
26
+ try { parsed = new URL(url); } catch (e) { console.error(`"${url}" is not a valid url`); process.exit(2); }
27
+ const dir = path.join(WORK, "targets", name);
28
+ if (fs.existsSync(path.join(dir, "target.json"))) {
29
+ console.error(`targets/${name} already exists — pick another name, or continue its loop: ppk human ${name} verify`);
30
+ process.exit(1);
31
+ }
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name, url: parsed.href, width: +(widthArg || 1512), adopted: true }, null, 2) + "\n");
34
+ fs.writeFileSync(path.join(dir, "NOTES.md"), `# ${name} — ${parsed.href} (ADOPTED external build — human loop only)
35
+
36
+ Built by an external tool (ditto / other); the kit runs the human-review loop, not the
37
+ pixel gates. Record each round: what the human flagged, the root cause, the fix.
38
+
39
+ | round | flagged | root cause | fix |
40
+ |-------|---------|------------|-----|
41
+ | | | | |
42
+ `);
43
+ console.log(`✓ adopted targets/${name} (original: ${parsed.href})
44
+
45
+ next:
46
+ 1. run your build's dev server (e.g. ditto output: npm i && npm run dev)
47
+ 2. ppk tunnel ${name} --url http://localhost:3000 # public, reachability-verified
48
+ 3. ppk human ${name} file [--region "…"] # first review round
49
+ loop: fix what the human pins → refile with --changelog "…" → until the verdict approves.`);
50
+ }
51
+
52
+ main();
@@ -0,0 +1,55 @@
1
+ // harness/agent-setup.js — `ppk agent-setup`: teach the user's AI agent to use the kit.
2
+ //
3
+ // The kit's entire new-user experience is "install, then ask your agent to clone a
4
+ // site" — this is the command that makes the second half true. It installs the kit's
5
+ // clone-site skill into the user's agent skill directory (~/.claude/skills/), where
6
+ // Claude Code auto-discovers it: from then on, "clone https://example.com" in any
7
+ // session triggers the skill, which drives the full ppk pipeline and treats the user
8
+ // as the reviewer.
9
+ //
10
+ // USAGE: ppk agent-setup [--force] (--force overwrites an existing install)
11
+ "use strict";
12
+
13
+ const fs = require("fs");
14
+ const os = require("os");
15
+ const path = require("path");
16
+
17
+ const PKG = path.resolve(__dirname, "..");
18
+
19
+ function install(homeDir, force) {
20
+ // Every skill the kit ships lives in PKG/skill/<skill-name>/SKILL.md — install them
21
+ // all (pixel-perfect-clone: the full gated pipeline; ditto-finish: bring-your-own
22
+ // builder + the human loop). One kit, several front doors.
23
+ const skillRoot = path.join(PKG, "skill");
24
+ if (!fs.existsSync(skillRoot)) return { ok: false, message: `kit skills missing at ${skillRoot} — broken install; reinstall pixel-perfect-kit`, installed: [] };
25
+ const names = fs.readdirSync(skillRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && fs.existsSync(path.join(skillRoot, e.name, "SKILL.md"))).map((e) => e.name);
26
+ if (!names.length) return { ok: false, message: `no skills found under ${skillRoot} — broken install; reinstall pixel-perfect-kit`, installed: [] };
27
+
28
+ const installed = [], skipped = [];
29
+ for (const n of names) {
30
+ const dest = path.join(homeDir, ".claude", "skills", n, "SKILL.md");
31
+ if (fs.existsSync(dest) && !force) { skipped.push(n); continue; }
32
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
33
+ fs.copyFileSync(path.join(skillRoot, n, "SKILL.md"), dest);
34
+ installed.push(n);
35
+ }
36
+ if (!installed.length) {
37
+ return { ok: false, message: `already installed (${skipped.join(", ")}) — re-run with --force to overwrite with this kit version's copies`, installed };
38
+ }
39
+ return { ok: true, installed, message: `✓ installed skill(s): ${installed.join(", ")}${skipped.length ? ` (kept existing: ${skipped.join(", ")} — --force to refresh)` : ""}
40
+ → ${path.join(homeDir, ".claude", "skills")}
41
+ Your agent picks them up on its next session. Then just ask it:
42
+ "Clone https://example.com pixel-perfect." (the full gated pipeline)
43
+ "Clone https://example.com with ditto and polish it." (ditto build + human rounds)
44
+ (You'll be the reviewer: answer the pings — pin what looks wrong, always pick a verdict button.)` };
45
+ }
46
+
47
+ function main() {
48
+ const force = process.argv.includes("--force");
49
+ const r = install(os.homedir(), force);
50
+ console.log(r.message);
51
+ process.exit(r.ok ? 0 : 1);
52
+ }
53
+
54
+ if (require.main === module) main();
55
+ module.exports = { install };
@@ -0,0 +1,243 @@
1
+ // harness/behavior-selftest.js — guards the `behavior` phase gate (harness/workflow.js's
2
+ // behaviorGate). Offline, socket-free: drives the gate purely through
3
+ // targets/<name>/behaviors-live.json / behaviors-clone.json / behavior-deviations.json
4
+ // fixtures on disk (no browser, no network) — the same file-fixture pattern
5
+ // workflow-selftest.js uses for live.json/clone.json.
6
+ //
7
+ // Asserts the contracts the design contract calls out explicitly:
8
+ // - blocks on missing inventory (live, then clone)
9
+ // - blocks on a live behavior absent from the clone inventory, NAMED in the reason
10
+ // - blocks on out-of-tolerance values (speed, opacity, transform mismatch)
11
+ // - passes on within-tolerance reproduction
12
+ // - passes on a documented deviation
13
+ // - fails a paint-over of "no discovery ran" (empty behaviors + no discovery metadata)
14
+ // - passes when discovery legitimately found nothing (metadata present, behaviors empty)
15
+ "use strict";
16
+ const fs = require("fs");
17
+ const os = require("os");
18
+ const path = require("path");
19
+ const cp = require("child_process");
20
+
21
+ const KIT = path.resolve(__dirname, "..");
22
+ const WF = path.join(KIT, "harness", "workflow.js");
23
+ let failed = 0;
24
+ const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
25
+
26
+ const run = (args) => { const r = cp.spawnSync(process.execPath, [WF, ...args], { encoding: "utf8", cwd: KIT }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
27
+
28
+ const NAME = "behaviorselftest_" + process.pid;
29
+ const dir = path.join(KIT, "targets", NAME);
30
+ const cleanup = () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} };
31
+ process.on("exit", cleanup);
32
+ cleanup();
33
+ fs.mkdirSync(path.join(dir, "clone", "assets"), { recursive: true });
34
+
35
+ const writeJson = (f, o) => fs.writeFileSync(path.join(dir, f), JSON.stringify(o, null, 2));
36
+
37
+ // discovery metadata that COUNTS as "discovery actually ran" — mirrors the shape
38
+ // tools/behavior-capture.js's discover() emits
39
+ const discoveryMeta = (extra) => Object.assign({
40
+ startedAt: "2026-07-02T00:00:00.000Z", endedAt: "2026-07-02T00:00:03.000Z", durationMs: 3000,
41
+ scrollSweep: { from: 0, to: 4000, steps: 6 },
42
+ observeMs: 1500,
43
+ elementsScanned: 812,
44
+ staticCandidateCount: 5,
45
+ keyframesFound: ["logo-belt"],
46
+ hoverTriggersProbed: [],
47
+ marqueeSelectorsProbed: [],
48
+ }, extra || {});
49
+
50
+ console.log("behavior-selftest — the `behavior` phase gate");
51
+ run(["init", NAME]); // seed workflow.json (harmless if it errors on a fresh dir — ignored)
52
+
53
+ // ── missing live inventory blocks ────────────────────────────────────────────
54
+ ok(run(["gate", NAME, "behavior"]).code === 1, "blocks with no behaviors-live.json");
55
+ const missLive = run(["gate", NAME, "behavior"]);
56
+ ok(/behaviors-live\.json missing/.test(missLive.out), "reason names the missing live inventory file");
57
+
58
+ // ── a paint-over of "no discovery ran" fails: empty behaviors, NO discovery metadata ──
59
+ writeJson("behaviors-live.json", { url: "https://example.com/", behaviors: {} });
60
+ const paintOver = run(["gate", NAME, "behavior"]);
61
+ ok(paintOver.code === 1, "blocks when behaviors-live.json has an empty inventory but NO discovery metadata (paint-over)");
62
+ ok(/discovery pass metadata|paint-over/i.test(paintOver.out), "reason calls out the missing discovery evidence, not just 'no behaviors'");
63
+
64
+ // ── legitimate "discovery ran, found nothing" passes and cites the evidence ──
65
+ writeJson("behaviors-live.json", { url: "https://example.com/", discovery: discoveryMeta(), behaviors: {} });
66
+ const noneFound = run(["gate", NAME, "behavior"]);
67
+ ok(noneFound.code === 0, "passes when discovery metadata is present and genuinely found zero behaviors");
68
+ ok(/elements scanned/.test(noneFound.out) && /scroll swept 0.4000px/.test(noneFound.out.replace(/→/, ".")), "pass reason cites discovery metadata (elements scanned, scroll sweep) as evidence discovery ran");
69
+
70
+ // ── a live behavior with no clone inventory at all blocks ───────────────────
71
+ writeJson("behaviors-live.json", {
72
+ url: "https://example.com/", discovery: discoveryMeta(),
73
+ behaviors: { "marquee:logo_belt": { trigger: "load", kind: "marquee", measured: { pxPerSec: 35.4 } } },
74
+ });
75
+ const noClone = run(["gate", NAME, "behavior"]);
76
+ ok(noClone.code === 1 && /behaviors-clone\.json missing/.test(noClone.out) && /marquee:logo_belt/.test(noClone.out), "blocks when clone inventory is entirely absent, names the live behavior waiting on it");
77
+
78
+ // ── a live behavior absent from the clone inventory blocks, NAMED ───────────
79
+ writeJson("behaviors-clone.json", { url: "http://localhost:8080/", discovery: discoveryMeta(), behaviors: {} });
80
+ const missingOne = run(["gate", NAME, "behavior"]);
81
+ ok(missingOne.code === 1 && /MISSING/.test(missingOne.out) && /marquee:logo_belt/.test(missingOne.out), "blocks when a live behavior is silently missing from the clone, and names it");
82
+
83
+ // ── out-of-tolerance speed blocks (2x speed marquee must not slip through) ──
84
+ writeJson("behaviors-clone.json", {
85
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
86
+ behaviors: { "marquee:logo_belt": { trigger: "load", kind: "marquee", measured: { pxPerSec: 70.8 } } }, // exactly 2x live
87
+ });
88
+ const tooFast = run(["gate", NAME, "behavior"]);
89
+ ok(tooFast.code === 1 && /pxPerSec/.test(tooFast.out) && /marquee:logo_belt/.test(tooFast.out), "blocks a 2x-speed marquee (out of the ±15% pxPerSec tolerance)");
90
+
91
+ // ── within-tolerance speed passes (absorbs sampling noise) ───────────────────
92
+ writeJson("behaviors-clone.json", {
93
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
94
+ behaviors: { "marquee:logo_belt": { trigger: "load", kind: "marquee", measured: { pxPerSec: 36.9 } } }, // ~4% off — within ±15%
95
+ });
96
+ const closeEnough = run(["gate", NAME, "behavior"]);
97
+ ok(closeEnough.code === 0, "passes a marquee sampled ~4% off live speed (within the documented ±15% tolerance)");
98
+
99
+ // ── trigger technique mismatch blocks even if the numbers happen to line up ──
100
+ writeJson("behaviors-live.json", {
101
+ url: "https://example.com/", discovery: discoveryMeta(),
102
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { before: { opacity: 0, transform: "translateY(20px)", filter: "none" }, after: { opacity: 1, transform: "none", filter: "none" } } } },
103
+ });
104
+ writeJson("behaviors-clone.json", {
105
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
106
+ behaviors: { "reveal:hero_heading": { trigger: "load", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } } },
107
+ });
108
+ const wrongTrigger = run(["gate", NAME, "behavior"]);
109
+ ok(wrongTrigger.code === 1 && /trigger/.test(wrongTrigger.out) && /reveal:hero_heading/.test(wrongTrigger.out), "blocks when the reproduction technique (trigger) differs from live, even with matching end-state numbers");
110
+
111
+ // ── out-of-tolerance end-state opacity blocks (clone frozen short of live's reveal) ──
112
+ writeJson("behaviors-clone.json", {
113
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
114
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 0.4, transform: "none", filter: "none" } } } },
115
+ });
116
+ const frozen = run(["gate", NAME, "behavior"]);
117
+ ok(frozen.code === 1 && /opacity/.test(frozen.out) && /hero_heading/.test(frozen.out), "blocks a reveal frozen short of live's end-state opacity");
118
+
119
+ // ── out-of-tolerance end-state transform blocks (live fully translates away, clone doesn't) ──
120
+ writeJson("behaviors-live.json", {
121
+ url: "https://example.com/", discovery: discoveryMeta(),
122
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "translateY(-40px)", filter: "none" } } } },
123
+ });
124
+ writeJson("behaviors-clone.json", {
125
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
126
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "translateY(-10px)", filter: "none" } } } },
127
+ });
128
+ const wrongTransform = run(["gate", NAME, "behavior"]);
129
+ ok(wrongTransform.code === 1 && /transform/.test(wrongTransform.out) && /hero_heading/.test(wrongTransform.out), "blocks when the reproduced end-state transform string doesn't match live's exactly");
130
+
131
+ // ── matrix translation jitter passes (computed transforms are matrices; layout rounds them) ──
132
+ writeJson("behaviors-live.json", {
133
+ url: "https://example.com/", discovery: discoveryMeta(),
134
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "matrix(1, 0, 0, 1, 0, -40.003)", filter: "none" } } } },
135
+ });
136
+ writeJson("behaviors-clone.json", {
137
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
138
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "matrix(1, 0, 0, 1, 0, -40.2)", filter: "none" } } } },
139
+ });
140
+ ok(run(["gate", NAME, "behavior"]).code === 0, "passes matrix transforms whose translation differs by sub-0.5px layout rounding (matrix-aware compare, not string equality)");
141
+
142
+ // ── observed-mutation (interval rotations) compare by PRESENCE, not frame snapshots ──
143
+ // A rotating hero's transform at snapshot time is whatever frame it was on — two captures of
144
+ // a continuously-mutating element can never agree on end-state floats. Presence + trigger
145
+ // match are the contract; a clone that ALSO rotates passes, one missing the key blocks.
146
+ writeJson("behaviors-live.json", {
147
+ url: "https://example.com/", discovery: discoveryMeta(),
148
+ behaviors: { "mutation:div.hero-rotator": { trigger: "mutation", kind: "observed-mutation", measured: { after: { opacity: 1, transform: "matrix(1, 0, 0, 1, 0, -333.2)", filter: "none" } } } },
149
+ });
150
+ writeJson("behaviors-clone.json", {
151
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
152
+ behaviors: { "mutation:div.hero-rotator": { trigger: "mutation", kind: "observed-mutation", measured: { after: { opacity: 0.4, transform: "matrix(1, 0, 0, 1, 0, -80)", filter: "none" } } } },
153
+ });
154
+ ok(run(["gate", NAME, "behavior"]).code === 0, "passes an interval rotation whose frame snapshots differ (observed-mutation: presence+trigger are the contract, frames are nondeterministic)");
155
+ writeJson("behaviors-clone.json", { url: "http://localhost:8080/", discovery: discoveryMeta(), behaviors: {} });
156
+ ok(run(["gate", NAME, "behavior"]).code === 1, "an observed-mutation key missing from the clone still blocks (the clone must rotate at all)");
157
+
158
+ // reset live back to the simple opacity-reveal case used by the remaining assertions below
159
+ writeJson("behaviors-live.json", {
160
+ url: "https://example.com/", discovery: discoveryMeta(),
161
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { before: { opacity: 0, transform: "translateY(20px)", filter: "none" }, after: { opacity: 1, transform: "none", filter: "none" } } } },
162
+ });
163
+
164
+ // ── within-tolerance reproduction passes ─────────────────────────────────────
165
+ writeJson("behaviors-clone.json", {
166
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
167
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 0.98, transform: "none", filter: "none" } } } }, // opacity 0.02 off — within ±0.05
168
+ });
169
+ ok(run(["gate", NAME, "behavior"]).code === 0, "passes a reveal that lands within the ±0.05 opacity tolerance with matching transform/trigger");
170
+
171
+ // ── multiple live behaviors: one within tolerance, one documented deviation ─
172
+ writeJson("behaviors-live.json", {
173
+ url: "https://example.com/", discovery: discoveryMeta(),
174
+ behaviors: {
175
+ "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } },
176
+ "generative:webgl_bg": { trigger: "load", kind: "canvas-generative", measured: { after: { opacity: 1 } } },
177
+ },
178
+ });
179
+ writeJson("behaviors-clone.json", {
180
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
181
+ behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } } },
182
+ });
183
+ const undocumented = run(["gate", NAME, "behavior"]);
184
+ ok(undocumented.code === 1 && /generative:webgl_bg/.test(undocumented.out) && /MISSING/.test(undocumented.out), "an undocumented missing behavior still blocks even when other behaviors reproduce cleanly");
185
+ writeJson("behavior-deviations.json", { "generative:webgl_bg": { reason: "WebGL generative canvas — irreproducible statically, per-frame noise procedurally generated" } });
186
+ const withDeviation = run(["gate", NAME, "behavior"]);
187
+ ok(withDeviation.code === 0 && /documented deviation/.test(withDeviation.out) && /generative:webgl_bg/.test(withDeviation.out), "passes once the irreproducible behavior is documented in behavior-deviations.json, and says so");
188
+
189
+ // a deviation entry with an empty/missing reason doesn't count as documented (never a free pass)
190
+ writeJson("behavior-deviations.json", { "generative:webgl_bg": { reason: "" } });
191
+ ok(run(["gate", NAME, "behavior"]).code === 1, "an empty-reason deviation entry does NOT count as documented — still blocks");
192
+ fs.rmSync(path.join(dir, "behavior-deviations.json"));
193
+
194
+ // ── DECLARED rows (environment-inverted runs): every supposed-to-move element needs a
195
+ // disposition — reproduced (descriptor observed firing on the clone) or excused.
196
+ // Silently dropping one is how a single invented animation replaces two real ones. ──
197
+ writeJson("behaviors-live.json", {
198
+ url: "https://example.com/", discovery: discoveryMeta(),
199
+ behaviors: {},
200
+ declared: {
201
+ "declared:video#intro-video": { hints: ["video:scripted:none", "attr:data-video-load-kf"], startState: { opacity: 1, transform: "none", filter: "none" }, text: null },
202
+ "declared:span.gradient-headline": { hints: ["transition-from-start-state"], startState: { opacity: 0, transform: "none", filter: "none" }, text: "18MP Center Stage front" },
203
+ },
204
+ });
205
+ writeJson("behaviors-clone.json", { url: "http://localhost:8080/", discovery: discoveryMeta(), behaviors: {} });
206
+ const undisposed = run(["gate", NAME, "behavior"]);
207
+ ok(undisposed.code === 1 && /DECLARED/.test(undisposed.out) && /behavior-worksheet/.test(undisposed.out), "undisposed declared rows block, pointing at the worksheet");
208
+ ok(/intro-video/.test(undisposed.out), "the blocked reason names the undisposed declared row");
209
+
210
+ // reproduced via descriptor match: the clone observed the same element firing under an
211
+ // observed prefix (reveal:) — declared:span.gradient-headline ↔ reveal:span.gradient-headline
212
+ writeJson("behaviors-clone.json", {
213
+ url: "http://localhost:8080/", discovery: discoveryMeta(),
214
+ behaviors: { "reveal:span.gradient-headline": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } } },
215
+ });
216
+ writeJson("behavior-deviations.json", { "declared:video#intro-video": { reason: "phone-screen video asset not shipped in the captured DOM — awaiting reviewer description of live playback" } });
217
+ const disposed = run(["gate", NAME, "behavior"]);
218
+ ok(disposed.code === 0 && /2 declared row\(s\) disposed \(1 reproduced, 1 excused\)/.test(disposed.out), "declared rows pass once each is reproduced (descriptor match) or excused, and the receipt counts them");
219
+
220
+ // worksheet CLI mirrors the same disposition logic + prints poll questions for unresolved
221
+ {
222
+ const cpx = require("child_process");
223
+ fs.rmSync(path.join(dir, "behavior-deviations.json"));
224
+ const w1 = cpx.spawnSync(process.execPath, [path.join(KIT, "tools", "behavior-worksheet.js"), NAME], { encoding: "utf8", cwd: KIT });
225
+ ok(w1.status === 1 && /UNRESOLVED/.test(w1.stdout + w1.stderr) && /human-qa\.js poll/.test(w1.stdout + w1.stderr), "worksheet exits 1 on unresolved rows and prints a ready-to-send one-sided poll question");
226
+ writeJson("behavior-deviations.json", { "declared:video#intro-video": { reason: "awaiting reviewer description" } });
227
+ const w2 = cpx.spawnSync(process.execPath, [path.join(KIT, "tools", "behavior-worksheet.js"), NAME], { encoding: "utf8", cwd: KIT });
228
+ ok(w2.status === 0 && /worksheet clean/.test(w2.stdout), "worksheet exits 0 once every row is disposed");
229
+ }
230
+ fs.rmSync(path.join(dir, "behavior-deviations.json"), { force: true });
231
+ fs.rmSync(path.join(dir, "behaviors-live.json"), { force: true });
232
+ fs.rmSync(path.join(dir, "behaviors-clone.json"), { force: true });
233
+
234
+ // ── advance wiring: behavior sits between strict and human in phaseOrder ────
235
+ {
236
+ const { PHASES } = require(WF);
237
+ const keys = PHASES.map((p) => p.key);
238
+ ok(keys.indexOf("behavior") === keys.indexOf("strict") + 1, "behavior phase immediately follows strict");
239
+ ok(keys.indexOf("human") === keys.indexOf("behavior") + 1, "human phase immediately follows behavior");
240
+ }
241
+
242
+ console.log(failed ? `\n❌ behavior-selftest: ${failed} assertion(s) failed.` : "\n✓ behavior-selftest: all assertions pass — the gate blocks when unmet and passes when met.");
243
+ process.exit(failed ? 1 : 0);