visual-arbiter
Mechanical visual verifier invoked by bob at the UI-INTEGRATED → UI-VERIFIED gate (ecosystem-keystone §2.6, §2.9, §5.5, §5.6). Mirrors the CLI shape of verification-arbiter (S027) exactly, but the decision path is pure Python — puppeteer is used only for DOM measurement, never for judgment.
When to invoke
From bob at the UI-INTEGRATED → UI-VERIFIED transition (ecosystem-keystone HARD-RULE 6). Runs in sequence: visual-arbiter first; if it rejects with micro-drift only, bob may then invoke design-drift-arbiter for auto-approval. Either arm rejecting blocks UI-VERIFIED.
Prerequisites:
- Frozen
design-skeleton.v1 (signed index.yaml + per-screen yamls in .design-ledger/skeletons/)
- Built product reachable via
file:// or http(s):// URL
claims.open_visual_verification_request(...) must have opened a request for the produced tuple BEFORE invoking the arbiter
Invocation
Subprocess, not an Agent. Bob spawns it as:
python3 ~/.claude/skills/_meta/visual_arbiter_spawn.py \
<skeleton_path> <skeleton_hash> <request_id> <attempt_id> \
<prior_state_version> <built_product_url> <product_hash> \
<inventory_hash> <runner_version> <rubric_version>
All 10 arguments are positional and required. Bob captures stdout; the arbiter writes nothing to disk.
Input tuple (8 fields echoed back verbatim — §2.9)
| Field |
Format |
Purpose |
request_id |
32-hex |
Causal linkage to the open visual-verification request |
attempt_id |
non-empty string |
Distinguishes retries against the same component |
prior_state_version |
non-empty string |
Pins verdict to ledger state at request-open time |
skeleton_hash |
64-hex (sha256) |
Content hash of the frozen skeleton bundle |
product_hash |
64-hex (sha256) |
Built product two-layer hash (source dir + rendered HTML) |
inventory_hash |
64-hex (sha256) |
env-adoption inventory at verification time |
runner_version |
non-empty string |
trusted_runner version that produced the bundle |
rubric_version |
non-empty string |
Arbiter's evaluation rubric version (see below) |
Tuple mismatch on any field → bob's claims.consume_visual_verdict returns rejected_tuple_mismatch and discards the verdict.
Output (stdout only)
One JSON object matching visual-verdict.v1 (design §2.9). Top-level verdict:
pass — every element at every required breakpoint verified; no fail-status element verdicts; no blocker concerns
warn — all elements verified but ≥1 warning-severity concern (e.g. fonts.ready >2s)
reject — ≥1 element_verdict has status: fail (missing_from_dom, bbox_drift beyond tolerance, token_mismatch, or dead_handler)
AUDIT_UNAVAILABLE — reserved for the spawn script on environmental failure (chrome crash, schema violation). Bob MUST escalate to user; NEVER auto-approve.
Element verdict fields (§2.6):
missing_from_dom — selector resolves to nothing at the given breakpoint
bbox_drift_px: {x, y, w, h} — per-dim signed drift; compared against tolerance_for(element) = min(must_satisfy.tolerance_px, spacing.unit_px / 2)
token_mismatch: {field, computed, expected} — hardcoded hex/rgb found in inline style (or computed value fails var(--...) indirection)
dead_handler: [{event, binds_to, reason}] — interaction declared but no listener fires (and uri.resolve confirms target unreachable if binds_to is set)
interactions_ok: [{event, binds_to, status}] — per-interaction success
Overall:
- pass iff every element verdict is
pass AND no blocker concerns
- reject iff any element verdict is
fail
- warn iff all pass but warnings exist
Exit codes
| Code |
Meaning |
0 |
Valid schema-compliant verdict emitted on stdout; all 8 tuple fields echoed correctly |
3 |
Environmental / usage error (wrong argv count, hash not 64-hex, unreadable skeleton) |
4 |
AUDIT_UNAVAILABLE — measurement subprocess crashed, timed out, chrome unreachable, or produced unparseable output |
Design §2.6 / §5.6: arbiter never writes .design-ledger/visual-verdicts/. Bob is the sole ledger writer (CB4 preserved).
Decision rubric (v1.0.0 — pure-Python, no LLM)
Source of truth: this SKILL.md plus visual_arbiter_spawn.py, both hashed. Bump the rubric version (semver) when changes affect verdict semantics. v1.0.0 rules:
Per-element at every required breakpoint. For each element in each screen's elements[], iterate must_satisfy.required_breakpoints. If the element declares null at a breakpoint, it is correctly hidden there — no verification needed. Otherwise:
bbox check. Measured getBoundingClientRect() compared to declared bbox.<breakpoint>. Per-dim drift = measured - declared. Element passes bbox iff every abs(drift[dim]) <= tolerance_px where tolerance_px = min(must_satisfy.tolerance_px, spacing.unit_px // 2) — taken directly from the skeleton's own declarations; NEVER a hardcoded constant.
Token back-resolution. For each tokens_used mapping, the element's computed styles must go through a var(--<token-name>) reference (e.g. var(--accent-sun) for $color.accent.sun) OR at minimum NOT contain a hardcoded hex/rgb in its inline style attribute. Hardcoded hex in inline style → token_mismatch fail with {field, expected, computed}.
Interaction wiring. For each declared interactions[].event:
visual_only: true — no binding required; pass automatic
binds_to == null and NOT visual_only → schema violation → dead_handler
binds_to set → arbiter fires the event via dispatchEvent, then checks (a) inline on* attribute, (b) data-arbiter-wired="true" opt-in flag, OR (c) an el.__arbiter_handler_ran flag the handler itself set. None of those → dead_handler fail
- If
uri module available: binds_to URI resolved via uri.resolve(binds_to, project_root); UriError → dead_handler with reason binds_to unresolvable
Font-load concern. fonts.ready + 300ms wait is mandatory. Wait >2000ms → warning concern (does not block pass but downgrades to warn). Also emits claude_observe('external_tool_slow', ...) fail-open.
Chrome crash handling. Measurement subprocess non-zero exit or timeout → emit external_tool_fail / external_tool_slow observation (fail-open) and exit with code 4 + AUDIT_UNAVAILABLE.
Tuple echo discipline. All 8 tuple fields echoed verbatim — bob's claims.consume_visual_verdict rejects on any mismatch.
Env vars
| Variable |
Default |
Purpose |
VISUAL_ARBITER_CHROME_PATH |
/bin/google-chrome |
Chrome/Chromium binary (must match what puppeteer-core can drive) |
VISUAL_ARBITER_NODE_BIN |
node |
Node binary for the measurement subprocess |
VISUAL_ARBITER_TIMEOUT_S |
180 |
Measurement subprocess timeout (chrome launch + per-bp measurement) |
Relationship to other verifiers
| Arbiter |
Scope |
Decision engine |
verification-arbiter (S027) |
Functional — test coverage against frozen plan |
Cold Claude + coverage scoring |
visual-arbiter (this) |
Visual — bbox + tokens + interactions against frozen skeleton |
Pure Python; puppeteer = measurement only |
design-drift-arbiter (WP-10) |
Micro-drift auto-approval post-rejection |
Pure Python profile rules |
Visual-arbiter runs before design-drift-arbiter. bob invokes drift-arbiter only if visual-arbiter rejected with micro-drift-only reasons (bbox within expanded profile tolerance OR token swap within same family). See ecosystem-keystone §5.6 / HARD-RULE 6.
Scope boundaries
| In scope |
Out of scope |
| Measuring bbox/tokens/interactions at each breakpoint |
Creating/amending the skeleton (visual-architect owns) |
| Mechanical comparison against declared tolerances |
Judgment calls about what a user "really wants" — v2 deferral (§1.3) |
| 8-field tuple echo |
Consuming the verdict or writing visual-verdicts/ (bob owns via claims.consume_visual_verdict) |
Emitting AUDIT_UNAVAILABLE via exit 4 |
Model-emitted AUDIT_UNAVAILABLE (there is no model in the verdict path) |
fail-open claude_observe for chrome slow/fail |
Being authoritative for observability — that's observability skill (S026) |
References
- Design doc:
docs/plans/2026-04-23-ecosystem-keystone-design.md (§2.6 pure-Python arbiter, §2.9 visual-verdict.v1 schema, §5.5 claims.consume_visual_verdict, §5.6 HARD-RULE 6, §5.9 external-wrapper observations)
- Contract map:
progress/contract-map.yaml — component visual-arbiter (TS-VAR-01..05)
- Sibling subprocess (S027):
~/.claude/skills/_meta/verification_arbiter_spawn.py
- Binary:
~/.claude/skills/_meta/visual_arbiter_spawn.py
- Measurement driver:
~/.claude/skills/_meta/visual_arbiter_measure.mjs
- Tests:
~/.claude/skills/visual-arbiter/tests/test_visual_arbiter.py (TS-VAR-01..05)
Source: joogy06/agent-foundry — distributed by TomeVault.
1---2name: joogy06-agent-foundry-visual-arbiter3description: visual-arbiter4---56# visual-arbiter78Mechanical visual verifier invoked by bob at the UI-INTEGRATED → UI-VERIFIED gate (ecosystem-keystone §2.6, §2.9, §5.5, §5.6). Mirrors the CLI shape of `verification-arbiter` (S027) exactly, but the decision path is **pure Python** — puppeteer is used only for DOM measurement, never for judgment.910## When to invoke1112From bob at the UI-INTEGRATED → UI-VERIFIED transition (ecosystem-keystone HARD-RULE 6). Runs in sequence: visual-arbiter first; if it rejects with micro-drift only, bob may then invoke `design-drift-arbiter` for auto-approval. Either arm rejecting blocks UI-VERIFIED.1314Prerequisites:15- Frozen `design-skeleton.v1` (signed `index.yaml` + per-screen yamls in `.design-ledger/skeletons/`)16- Built product reachable via `file://` or `http(s)://` URL17- `claims.open_visual_verification_request(...)` must have opened a request for the produced tuple BEFORE invoking the arbiter1819## Invocation2021Subprocess, not an Agent. Bob spawns it as:2223```bash24python3 ~/.claude/skills/_meta/visual_arbiter_spawn.py \25 <skeleton_path> <skeleton_hash> <request_id> <attempt_id> \26 <prior_state_version> <built_product_url> <product_hash> \27 <inventory_hash> <runner_version> <rubric_version>28```2930All 10 arguments are positional and required. Bob captures stdout; the arbiter writes **nothing** to disk.3132## Input tuple (8 fields echoed back verbatim — §2.9)3334| Field | Format | Purpose |35|---|---|---|36| `request_id` | 32-hex | Causal linkage to the open visual-verification request |37| `attempt_id` | non-empty string | Distinguishes retries against the same component |38| `prior_state_version` | non-empty string | Pins verdict to ledger state at request-open time |39| `skeleton_hash` | 64-hex (sha256) | Content hash of the frozen skeleton bundle |40| `product_hash` | 64-hex (sha256) | Built product two-layer hash (source dir + rendered HTML) |41| `inventory_hash` | 64-hex (sha256) | env-adoption inventory at verification time |42| `runner_version` | non-empty string | trusted_runner version that produced the bundle |43| `rubric_version` | non-empty string | Arbiter's evaluation rubric version (see below) |4445Tuple mismatch on any field → bob's `claims.consume_visual_verdict` returns `rejected_tuple_mismatch` and discards the verdict.4647## Output (stdout only)4849One JSON object matching `visual-verdict.v1` (design §2.9). Top-level `verdict`:5051- `pass` — every element at every required breakpoint verified; no fail-status element verdicts; no blocker concerns52- `warn` — all elements verified but ≥1 warning-severity concern (e.g. fonts.ready >2s)53- `reject` — ≥1 element_verdict has `status: fail` (missing_from_dom, bbox_drift beyond tolerance, token_mismatch, or dead_handler)54- `AUDIT_UNAVAILABLE` — reserved for the spawn script on environmental failure (chrome crash, schema violation). Bob MUST escalate to user; NEVER auto-approve.5556Element verdict fields (§2.6):5758- `missing_from_dom` — selector resolves to nothing at the given breakpoint59- `bbox_drift_px: {x, y, w, h}` — per-dim signed drift; compared against `tolerance_for(element) = min(must_satisfy.tolerance_px, spacing.unit_px / 2)`60- `token_mismatch: {field, computed, expected}` — hardcoded hex/rgb found in inline style (or computed value fails var(--...) indirection)61- `dead_handler: [{event, binds_to, reason}]` — interaction declared but no listener fires (and uri.resolve confirms target unreachable if binds_to is set)62- `interactions_ok: [{event, binds_to, status}]` — per-interaction success6364Overall:65- pass iff every element verdict is `pass` AND no blocker concerns66- reject iff any element verdict is `fail`67- warn iff all pass but warnings exist6869## Exit codes7071| Code | Meaning |72|---|---|73| `0` | Valid schema-compliant verdict emitted on stdout; all 8 tuple fields echoed correctly |74| `3` | Environmental / usage error (wrong argv count, hash not 64-hex, unreadable skeleton) |75| `4` | `AUDIT_UNAVAILABLE` — measurement subprocess crashed, timed out, chrome unreachable, or produced unparseable output |7677Design §2.6 / §5.6: arbiter **never writes** `.design-ledger/visual-verdicts/`. Bob is the sole ledger writer (CB4 preserved).7879## Decision rubric (v1.0.0 — pure-Python, no LLM)8081**Source of truth: this SKILL.md plus `visual_arbiter_spawn.py`, both hashed.** Bump the rubric version (semver) when changes affect verdict semantics. v1.0.0 rules:82831. **Per-element at every required breakpoint.** For each element in each screen's `elements[]`, iterate `must_satisfy.required_breakpoints`. If the element declares `null` at a breakpoint, it is correctly hidden there — no verification needed. Otherwise:84852. **bbox check.** Measured `getBoundingClientRect()` compared to declared `bbox.<breakpoint>`. Per-dim drift = `measured - declared`. Element passes bbox iff every `abs(drift[dim]) <= tolerance_px` where `tolerance_px = min(must_satisfy.tolerance_px, spacing.unit_px // 2)` — taken directly from the skeleton's own declarations; NEVER a hardcoded constant.86873. **Token back-resolution.** For each `tokens_used` mapping, the element's computed styles must go through a `var(--<token-name>)` reference (e.g. `var(--accent-sun)` for `$color.accent.sun`) OR at minimum NOT contain a hardcoded hex/rgb in its inline `style` attribute. Hardcoded hex in inline style → `token_mismatch` fail with `{field, expected, computed}`.88894. **Interaction wiring.** For each declared `interactions[].event`:90 - `visual_only: true` — no binding required; pass automatic91 - `binds_to == null` and NOT visual_only → schema violation → `dead_handler`92 - `binds_to` set → arbiter fires the event via `dispatchEvent`, then checks (a) inline `on*` attribute, (b) `data-arbiter-wired="true"` opt-in flag, OR (c) an `el.__arbiter_handler_ran` flag the handler itself set. None of those → `dead_handler` fail93 - If `uri` module available: `binds_to` URI resolved via `uri.resolve(binds_to, project_root)`; UriError → `dead_handler` with reason `binds_to unresolvable`94955. **Font-load concern.** `fonts.ready + 300ms` wait is mandatory. Wait >2000ms → `warning` concern (does not block `pass` but downgrades to `warn`). Also emits `claude_observe('external_tool_slow', ...)` fail-open.96976. **Chrome crash handling.** Measurement subprocess non-zero exit or timeout → emit `external_tool_fail` / `external_tool_slow` observation (fail-open) and exit with code 4 + `AUDIT_UNAVAILABLE`.98997. **Tuple echo discipline.** All 8 tuple fields echoed **verbatim** — bob's `claims.consume_visual_verdict` rejects on any mismatch.100101## Env vars102103| Variable | Default | Purpose |104|---|---|---|105| `VISUAL_ARBITER_CHROME_PATH` | `/bin/google-chrome` | Chrome/Chromium binary (must match what puppeteer-core can drive) |106| `VISUAL_ARBITER_NODE_BIN` | `node` | Node binary for the measurement subprocess |107| `VISUAL_ARBITER_TIMEOUT_S` | `180` | Measurement subprocess timeout (chrome launch + per-bp measurement) |108109## Relationship to other verifiers110111| Arbiter | Scope | Decision engine |112|---|---|---|113| `verification-arbiter` (S027) | Functional — test coverage against frozen plan | Cold Claude + coverage scoring |114| `visual-arbiter` (this) | Visual — bbox + tokens + interactions against frozen skeleton | Pure Python; puppeteer = measurement only |115| `design-drift-arbiter` (WP-10) | Micro-drift auto-approval post-rejection | Pure Python profile rules |116117Visual-arbiter runs **before** design-drift-arbiter. bob invokes drift-arbiter only if visual-arbiter rejected with micro-drift-only reasons (bbox within expanded profile tolerance OR token swap within same family). See ecosystem-keystone §5.6 / HARD-RULE 6.118119## Scope boundaries120121| In scope | Out of scope |122|---|---|123| Measuring bbox/tokens/interactions at each breakpoint | Creating/amending the skeleton (visual-architect owns) |124| Mechanical comparison against declared tolerances | Judgment calls about what a user "really wants" — v2 deferral (§1.3) |125| 8-field tuple echo | Consuming the verdict or writing visual-verdicts/ (bob owns via claims.consume_visual_verdict) |126| Emitting `AUDIT_UNAVAILABLE` via exit 4 | Model-emitted `AUDIT_UNAVAILABLE` (there is no model in the verdict path) |127| fail-open `claude_observe` for chrome slow/fail | Being authoritative for observability — that's observability skill (S026) |128129## References130131- Design doc: `docs/plans/2026-04-23-ecosystem-keystone-design.md` (§2.6 pure-Python arbiter, §2.9 visual-verdict.v1 schema, §5.5 claims.consume_visual_verdict, §5.6 HARD-RULE 6, §5.9 external-wrapper observations)132- Contract map: `progress/contract-map.yaml` — component `visual-arbiter` (TS-VAR-01..05)133- Sibling subprocess (S027): `~/.claude/skills/_meta/verification_arbiter_spawn.py`134- Binary: `~/.claude/skills/_meta/visual_arbiter_spawn.py`135- Measurement driver: `~/.claude/skills/_meta/visual_arbiter_measure.mjs`136- Tests: `~/.claude/skills/visual-arbiter/tests/test_visual_arbiter.py` (TS-VAR-01..05)137138---139> Source: [joogy06/agent-foundry](https://github.com/joogy06/agent-foundry) — distributed by [TomeVault](https://tomevault.io).140<!-- tomevault:4.0:skill_md:2026-06-16 -->