# Command Model

Screenslop uses its own command model because the workflow starts from runtime evidence.

## Core commands

### `screenslop setup`

Detects first-use Apple project metadata, prepares `.screenslop/config.json`, and writes only after explicit confirmation. This is the Pixeltamer-style first invocation step for Screenslop, but it stays project-local because app config can contain private paths and bundle IDs.

Use it for:

- first-run project detection
- safe config dry-runs
- ambiguous target reporting
- explicit config writes after approval

Current behavior:

```bash
screenslop setup --json --dry-run
screenslop setup --json --yes
screenslop setup --project MyApp.xcodeproj --scheme MyApp --bundle-id com.example.MyApp --source-root MyApp --surface Settings --json --dry-run
```

`setup` refuses to write when project, scheme, bundle ID, or source root detection is ambiguous. Setup is configuration only; proof starts with runtime capture at `screenslop see`.

### `screenslop instructions`

Prints the compact coding-agent contract from the shipped CLI. Use this when an
agent host does not auto-load the installed Screenslop skill, or when you want
to check whether the local skill install matches the current CLI checkout.

Current behavior:

```bash
screenslop instructions
screenslop instructions --agent codex
screenslop instructions --agent claude --json
```

The output includes the dry-run-first setup rule, the runtime-evidence loop,
the fresh-bundle verify rule, private artifact stop rules, CLI package version,
and local skill status. This command does not inspect, capture, write config, or
change the app.

### `screenslop self-update`

Updates the globally installed Screenslop CLI after explicit confirmation.
Skill updates do not update the CLI binary, so agents should use `doctor` to
detect stale installs and this command to repair them when the user allows
global package changes.

Current behavior:

```bash
screenslop self-update
screenslop self-update --yes
screenslop self-update --dry-run
screenslop doctor --update-cli --yes
```

`self-update` runs `npm install -g screenslop@latest`. Interactive use prompts
before changing the global install. Non-interactive use needs `--yes`.
`--dry-run` prints the command without running package installation. If global
installs are blocked, use `npx -y screenslop@latest <command>` instead.

### `screenslop init`

Sets up the project connection.

Use it for:

- runtime checks
- scheme / workspace / bundle ID configuration
- Baguette / XcodeBuildMCP / simctl preferences
- artifact folder config
- default device configuration
- safe migration from the current config shape

This is setup, not design learning.

Current MVP behavior:

```bash
screenslop init
screenslop init --json --dry-run
screenslop init --json --migrate --dry-run
screenslop init --json --migrate --yes
screenslop init --scheme RuntimeSmoke --bundle-id dev.example.RuntimeSmoke --source-root Sources
```

`init` creates or migrates `.screenslop/config.json` with `schemaVersion: 1`.
That file is ignored because it can contain private app paths. Commit docs or
templates instead of committing local target config.

The config keeps `artifactsDir` as the canonical artifact-root field. `see` and
`matrix` use it when a valid config exists. `sourceHints` stays
evidence/source-location metadata; it is not a write scope and must not be
treated as `sourceRoot`.

Existing fields are preserved or mapped:

- `runtimePreference`
- `preferredRuntime`
- `defaultSurface`
- `defaultScheme`
- `defaultBundleId`
- `artifactsDir`
- `sourceHints`

New target fields:

- `schemaVersion: 1`
- `workspacePath`
- `projectPath`
- `defaultDevice`
- `sourceRoot`
- `designSources`

Safety rules:

- Existing config migration needs `--migrate`; JSON/non-interactive writes also need `--yes`.
- `--dry-run` never writes.
- `.screenslop` and `.screenslop/config.json` symlinks are rejected.
- `sourceRoot` and `artifactsDir` must resolve inside the repo for v0.1.
- `sourceRoot` must not point at `.git`, `.omx`, `node_modules`, `DerivedData`, `build`, or `artifacts`.
- `designSources` may point at repo-local design docs/folders or explicit external design-system folders; they are read-only and are redacted in CLI output.
- `designSources` must not point at home, filesystem roots, or blocked folders.
- `artifactsDir` must not point at `.git`, `.omx`, `node_modules`, `DerivedData`, `build`, or the repo root.
- `sourceRoot` and `artifactsDir` must not overlap.

`schemaVersion: 1` is the v0.1 config generation. It is a 0.x contract, so
future 0.x releases may change it with an explicit migration path.

### `screenslop learn`

Learns, checks, and refreshes the private project design profile.

This is the Screenslop design-learning path. It scans project files, common design docs, and configured `designSources`, writes `.screenslop/design-profile.json`, checks freshness, and refreshes while preserving user-authored rules. It extracts lightweight color, typography, spacing, radius, material, and SF Symbol tokens from Markdown tables/pairs plus common SwiftUI design-system patterns, including `DynamicTheme`, `Color(hex:)`, HSB colors, `Font.custom`, spacing/radius constants, materials, and SF Symbols. It skips build/checkouts and localization/generated files such as `L10n.swift` so copy catalogs do not become fake design tokens.

Use it for:

- scanning SwiftUI code, common design docs, and configured design-system sources
- creating the private profile from a dry-run preview
- checking whether the profile is current or stale
- refreshing source hashes, generated component hints, project tone/audience/category hints, and extracted token summaries
- preserving app-specific review rules across refreshes

Current flow:

```bash
screenslop learn --json --dry-run
screenslop learn --write --yes --json
screenslop learn --check --json
screenslop learn --refresh --json --dry-run
screenslop learn --refresh --write --yes --json
screenslop learn --surface Settings --json --dry-run
```

The private default output is `.screenslop/design-profile.json`. It stays ignored unless a project exports a redacted public profile. JSON writes need `--write --yes`; dry runs never write.

If the app uses a shared Swift package or sibling design-system repo, add it to `.screenslop/config.json` through setup/init:

```bash
screenslop init --design-source ../SharedDesignSystem --json --dry-run
screenslop init --design-source ../SharedDesignSystem --yes
```

Token extraction is intentionally heuristic. `learn` reports both raw token counts and trusted token counts in JSON summaries. If credible core buckets are still missing, or an external package is not configured, it records `profileGaps` so the design pass says what it cannot prove instead of pretending.

### `screenslop see`

Captures evidence for the current screen.

Outputs:

- screenshot
- accessibility tree
- logs
- evidence manifest
- summary

After the accessibility tree is captured, `see` waits 250ms, takes a throwaway second frame, and compares it to the screenshot. The verdict goes into `capture.stability` as `stable`, `unstable`, or `unknown`. The probe runs last on purpose: putting the wait between the screenshot and the AX capture would push the tree further from the frame it describes, widening the very correlation this tool depends on. The probe frame is written outside the bundle and deleted — it is a measurement, not evidence.

Three signals, because each is blind to something the others see:

1. **Global** — more than 1% of sampled pixels moving. On a real simulator a still screen changes 0% and a screen mid-transition about 40%.
2. **Per-tile** — the frame is split into a 16×16 grid and any single tile above 20% counts. A 44×44pt spinner is only 0.55% of the screen, so the global rule alone would never see it. The grid is evaluated at four independent axis offsets: with one fixed grid the same region scored 0.24 inside a tile and 0.10 straddling a corner, so detection depended on where the motion happened to sit.
3. **Localized neighbours** — around each changed sample, the number of other changed samples within a fixed radius is counted; enough neighbours means something was moving there. This is a radius search centred on real samples rather than a window free to sit anywhere, so two changes further apart than the radius are not neighbours even though some window could hold both. The radius is set by what the rotation sweep empirically catches, not derived from spinner geometry. Tiles measure *density*, which is the wrong question for a real activity indicator: it is thin arcs and gaps, not a filled square.

That third signal is monotonic by construction, which took three attempts. A global bounding box and then connected components both asked "is this region compact" — and compactness *falls* as samples are added, so two spinners in opposite corners, or a sparse diagonal bridging them, made adding motion **reduce** detection. A neighbour count only rises, which gives the invariant those designs lacked: if a set of changes is unstable, every superset stays unstable.

Sampling geometry sets the real limit, and the limiting dimension is stroke width rather than element size. The step is capped at 4px absolutely rather than derived from total pixel area: a 2pt stroke is 6px on a 3× phone but 4px on a 2× iPad, whose larger capture pushed an area-derived step to 8px and walked straight over it.

**The transition that matters is a spinner *turning*, not appearing.** A sustained indicator shows one phase in both captures, and only its leading and trailing edges differ — four to six changed samples. Swept across scales, starting angles, rotation steps, and lattice phases, every rotation of 22.5° or more is caught. A 10° step (a spinner at 40°/s, against the ~360°/s a real indicator runs) can still escape.

The floor is two changed samples within one radius, which is low on purpose: three real captures of a live simulator, four to six seconds apart, each reported `changedRatio: 0`, so the noise level is genuinely zero. One stray sample could be a codec artifact; two within one radius is a shape.

**Caret exemption.** That floor is below what a blinking caret produces, and no threshold separates a caret from a turning arc. So the caret is bounded by *where* it can legally be rather than by how much it changes: motion is exempt only when there is exactly one **focused** editable field, every changed sample falls inside it, and the motion spans no more than a caret's width. Motion outside the field, a field repainting its content, or two fields both claiming focus all still fail. The exemption is also refused whenever the change record was truncated — judging confinement from a prefix once let later out-of-field motion vanish from a strict superset.

This exists because a frame caught mid-animation produces a manifest identical to a clean one, and every rule downstream inherits it: layout math reads frames that were still moving, contrast reads colors that were still fading. `critique` raises `evidence.unstable-capture` (P1) for an unstable bundle, and `verify` gates on it (below). Short transitions usually finish before `see` reaches the screenshot, so the check earns its keep on sustained motion — loading states, spinners, momentum scrolling, video.

A capture whose stability is not proven does not report success. `capture.status` becomes `partial` and `see` exits non-zero, because reporting a clean capture while the stability step failed let a matrix cell, an agent, or CI carry on with evidence photographed mid-animation. The bundle is still written and still inspectable — it just stops claiming to be a settled capture.

Only a measured `stable` is a passing stability step. `unknown` means the probe itself failed and carries a `reason`; it is reported as a failed step rather than a quiet success, because an unproven capture that looks proven is the failure mode this whole check exists to prevent.

`see` also checks *which app* it photographed. Device selection answers which simulator; nothing answered which app, so pointing `see` at a simulator where the app was not installed captured the iOS home screen and reported a complete, stable capture. The accessibility root carries the frontmost app's display name — empty on the springboard — and it is compared against the name resolved from `defaultBundleId` via `simctl listapps`. The verdict lands in `capture.foreground`.

- **match** — the configured app was on screen.
- **mismatch** — a different app, no app at all, or the configured app is not installed on this simulator. The capture becomes `partial` and `see` exits non-zero. An app absent from a listing that succeeded is decisive: it cannot be the app in the screenshot.
- **unverified** — no `defaultBundleId`, or `xcrun` could not answer. Recorded, never failed; rejecting a capture over a missing config field would punish the wrong thing.

Unverified captures still record the app they saw, so a bundle can always answer the question later. The screen's own heading is recorded beside the `--surface` label as `screenTitle`, because the surface name is an operator claim that nothing validates — an app resumed on a different tab yields a bundle named `home` showing something else.

Device targeting, highest precedence first:

1. `--udid <udid>` — exact simulator UDID.
2. `--device <name>` — exact or partial simulator name.
3. `defaultDevice` from `.screenslop/config.json`.
4. The booted simulator.
5. The first available simulator.

Config beats a booted simulator on purpose. A stray simulator left running would
otherwise hijack the capture and produce an evidence bundle for the wrong app —
the capture step reports the mismatch instead of silently following whatever is
booted. `matrix` already resolved `defaultDevice` this way; `see` now matches it.

An explicit `--device` or `--udid` that matches nothing is an error. A
`defaultDevice` that matches nothing falls back to the booted simulator and says
so, so a deleted simulator does not block every capture.

### `screenslop critique`

Reviews evidence and produces findings.

Every finding needs a screenshot region, AX node, log line, source hint, or an explicit note that evidence is missing.

Deterministic rule families, by evidence source:

- **AX structure**: `ax.missing-name`, `ax.generic-name`, `evidence.*`, `logs.*`.
- **AX frame math**: `layout.touch-target`, `layout.offscreen-frame`, `layout.thumb-reach`, `layout.destructive-adjacency`, `layout.alignment-edges`, `layout.spacing-offgrid`, `layout.spacing-monotony`, `layout.empty-state-dead-end`, `platform.hamburger-menu`, `platform.stacked-modals`, `hierarchy.working-memory`, `typography.truncation-risk`.
- **Screenshot pixels**: `color.contrast`, `color.monochrome-mute`, `color.competing-accents`. Pixel rules sample the real capture through macOS `sips`; on machines without `sips` or on stub screenshots they skip silently instead of failing critique. Both JPEG and PNG captures are readable — `sips` turns alpha-bearing sources into BITFIELDS BMPs, which the parser decodes using their channel masks.

Captures are JPEG because Baguette writes nothing else, so pixel rules measure lossy data. Screenslop requests `--quality 1.0` rather than accepting Baguette's 0.85 default: measured against a lossless PNG of the same screen, that cuts worst-case channel error from ~71/255 to ~6/255 for roughly 2.4x the bytes. The error that matters concentrates on antialiased text edges, which is exactly where contrast sampling splits text from background.

Design review attributes screen accents to learned tokens in OKLCh, not RGB. Apps routinely render a token as a derived variant — darkened for legibility, or blended for a disabled state — and Euclidean RGB puts those variants 100+ away from their own token (measured at 109 and 132 on a real device) while their hue moves under two degrees. Matching on hue and chroma, with lightness reported as the change, turns a false `design.token-drift` ("an accent the profile never learned") into `design.token-derived-variant` ("your `warning` token, 22 OKLCh lightness points darker"). Hue is a candidate gate, not a verdict: near-neutral colors are refused outright because their hue is numerically unstable, and when two tokens in one hue family both explain a sample the item reports both and names neither.

When a design profile exists, `color.contrast` also names the token its sampled color came from — "this is your `Theme.warning` token rendered 22 OKLCh lightness points lighter" instead of just "sampled #E8C478". That is additive context only: it never creates a finding, changes a severity, moves a confidence, or alters a fingerprint, because the failing ratio is measured from the capture while the token name comes from a profile that may be stale. With no profile, or a color that cannot be traced, findings read exactly as they did before.

Which cluster is the text is decided from a ring sampled just *outside* the label frame, not from cluster size. Deciding by size silently reversed foreground and background for light-on-dark text and confidently named the background token; sampling the frame's own perimeter fixes the common case but still inverts on a tight frame whose glyphs reach the edge. A ring outside the frame cannot contain the label's glyphs at all. Frames flush against the image edge fall back to the inner perimeter, and when neither can separate the two clusters the ratio is still reported and attribution is omitted rather than guessed.

The reported ratio is measured from the recovered glyph color against that attributed background — not from the average of the text cluster. Most glyph samples land on anti-aliased edges, so the cluster average sits blended toward the background and understates the ratio, inventing failures for text that passes. Cluster averages are used only to decide whether the box holds an edge at all. When the glyph color cannot be attributed, the average is all that remains; the finding then says the ratio is a floor, since blending only ever moves the pair closer together and so can prove a pass but never a failure.

Attribution also refuses to name a token when a *different* token, drawn at partial opacity over the measured background, explains the sample equally well — checked in linear light, and applied to exact matches too, since landing on a token is not proof it was the token used. Neutrals get no exemption: `.opacity(0.6)` on a label color is ordinary SwiftUI, so "black at 60% over white" is a real alternative explanation for a gray, and gray text with both a muted token and a black token in the palette reports both candidates rather than picking one. That is deliberately noisier than naming a winner, because the alternative is being confidently wrong about the most common text on screen.

`collectCritique` never reads the design profile — the CLI resolves tokens and injects them via `colorTokens`, so no profile access happens inside the deterministic lane. The pure color math lives in `src/color/attribution.mjs`, a neutral module both lanes import; `tests/design-module.test.mjs` walks the critique import graph transitively to keep it that way.

`color.contrast` confidence reflects how far the measurement sits from the threshold, not just text size. A ratio far below the minimum is reported `high` regardless of text size; one sitting within sampling noise of the threshold drops to `low` and says so. Tiny text needs a wider margin to earn the same confidence, because anti-aliasing only ever understates the true ratio.

Frame-math and pixel rules are heuristics with deliberately conservative thresholds — they lean toward missing a marginal case over flooding a screen with noise, and their findings say so in the detail text.

Each critique also writes `trend.json` into the bundle, comparing findings against the newest same-surface sibling bundle: new findings, resolved findings, unchanged count, and per-severity deltas. With no eligible baseline the file records `no-baseline`. The trend uses bundle basenames only, so it stays redaction-safe.

Agent JSON contract:

- `bundle`, `evidence`, and generated artifact paths are repo-relative when the bundle lives inside the current project root.
- Those paths are absolute when the bundle lives outside the current project root, such as a copied bundle in `/tmp`.
- Artifact reads prefer files next to the bundle before falling back to repo-root paths from the manifest. This keeps shared or copied bundles self-contained.

### `screenslop fix`

Plans and optionally applies selected safe fixes from a critique bundle.

Default behavior is conservative: it writes `fix-plan.json` and `fix.md` into the bundle, then edits nothing unless `--apply` is passed and confirmed. In non-interactive runs, `--apply` also needs `--yes`.

Common forms:

```bash
screenslop fix artifacts/<run> --dry-run
screenslop fix artifacts/<run> --finding <id> --source-root <app-root> --dry-run --json
screenslop fix artifacts/<run> --finding <id> --source-root <app-root> --apply --yes --label "Save settings" --json
screenslop fix artifacts/<run> --finding <id> --source-root <app-root> --apply --yes --verify-command "npm test"
```

Options:

- `--finding <id>` selects one finding. Repeat it or pass comma-separated IDs for more than one.
- `--source-root <path>` limits source search and patching to that app source tree.
- `--dry-run` writes the plan/report and does not edit source.
- `--apply` enables source edits for auto-fixable findings. It requires at least one `--finding`; dry-run is the only mode that can plan all findings at once.
- `--yes` confirms non-interactive apply runs.
- `--label <text>` supplies the replacement label for accessibility-label fixes.
- `--verify-command <command>` runs a bounded verification command and records pass/fail in `fix-session.json`.
- `--json` prints parseable JSON only and never prompts. Use `--yes` with `--json --apply`.

MVP auto-fixes are deliberately narrow:

- `ax.missing-name` with a unique `.accessibilityIdentifier(...)`, `.reviewID(...)`, or `sourceHint` line.
- `ax.generic-name` with the same source certainty and a supplied label.
- `layout.touch-target` with a unique source match and no existing nearby frame modifier.

Unsupported or ambiguous findings still appear in the fix plan, but Screenslop does not edit source for them. `layout.offscreen-frame`, `logs.*`, evidence-quality findings, visible-label-only matches, and duplicate source matches are manual in this MVP.

No fresh evidence means no verified fix claim. Design findings need fresh evidence plus a fresh design review before they can be called `improved`, `unchanged`, `regressed`, or `needs-human-review`. A passed `--verify-command` is recorded as `verify-passed`; only a future recapture/critique loop should use `recapture-passed`.

### `screenslop verify`

Compares previous findings against fresh evidence and writes proof artifacts.

Verification gates on the fresh bundle's capture stability, because a finding can disappear for two very different reasons: someone fixed it, or the fresh screenshot caught an animation and the evidence never showed up. Those are indistinguishable from the finding list alone.

`verified-fixed` is a proof label, and only a measured `stable` earns it.

- Fresh capture **stable** — verification proceeds unchanged.
- Fresh capture **unstable** — `verified-fixed` becomes `needs-human-review`, with the measured change stated in the reason.
- Stability **not measured or unknown** — a bundle captured before this check existed, or a probe that failed. Also `needs-human-review`. Keeping old bundles readable is not the same as granting them proof they never established, and a status that contradicts its own explanation is worse than an honest downgrade.

A `still-present` finding keeps its status on an unstable capture but drops to `medium` confidence: motion can invent a finding too — a label caught mid-fade measures a contrast it never has at rest — so the match may itself be an artifact. The normalized verdict is echoed on the result as `freshStability`.

Verification also checks that the two bundles are evidence about the same thing. Findings are matched by fingerprint across whatever pair of bundles is named, so a finding can "disappear" simply because its node path stopped existing on a different screen — which reads as `verified-fixed` with full confidence. The captured app and the screen's own heading are compared, and a disagreement downgrades `verified-fixed` to `needs-human-review` and `still-present` from high to `medium`, on the same reasoning as the stability gate: a finding that vanished because a different screen was captured was never looked at.

Only observed values gate. The `--surface` label is recorded but never compared — it is the operator's word for the screen, so a rename would withdraw proof while the case this exists for (two bundles both labelled `home`, showing different screens) would slip through. A value that was never recorded never counts as a disagreement, so bundles predating this still verify. The verdict is echoed as `subject`.

MVP usage:

```bash
screenslop verify artifacts/<baseline-run> --fresh-bundle artifacts/<fresh-run>
screenslop verify artifacts/<baseline-run> --fresh-bundle artifacts/<fresh-run> --finding <id> --json
screenslop verify artifacts/<baseline-run> --fresh-bundle artifacts/<fresh-run> --refresh-critique --json
```

Options:

- `--fresh-bundle <path>` is required. Verification needs a fresh evidence bundle.
- `--finding <id>` selects one or more baseline findings. Repeat it or pass comma-separated IDs.
- `--refresh-critique` reruns critique on the fresh bundle before comparison.
- `--fix-session <path>` attaches optional context from a fix session. It does not prove the fix by itself.
- `--json` prints parseable JSON only and never prompts.

Outputs are written into the baseline bundle:

- `verification.json`
- `verification.md`

Statuses:

- `verified-fixed`: fresh critique no longer reports the same measured issue by stable evidence keys. Design findings never use this automatic status.
- `still-present`: fresh critique still reports the same measured rule and stable evidence key.
- `changed`: the same measured rule remains, but the stable evidence key changed.
- `unknown`: the baseline measured finding lacks enough stable evidence to prove fixed or still present.
- `improved`: fresh design review no longer reports the same design finding.
- `unchanged`: fresh design review still reports the same design finding.
- `regressed`: fresh design review reports the related design finding with higher severity.
- `needs-human-review`: fresh design review changed in a way the tool should not decide alone.
- `missing-baseline`: a requested finding ID was not in the baseline findings.

The MVP does not capture fresh evidence itself. Run `screenslop see`, then `screenslop critique`, then `screenslop verify`. No fresh evidence, no verified claim.

## MVP end-to-end flow

The contract flow is deliberately explicit:

```bash
screenslop see --surface Settings --json
screenslop critique artifacts/<baseline-run> --json
screenslop fix artifacts/<baseline-run> --finding <id> --source-root <app-root> --apply --yes --label "Save settings" --json
screenslop see --surface Settings --json
screenslop critique artifacts/<fresh-run> --json
screenslop verify artifacts/<baseline-run> --fresh-bundle artifacts/<fresh-run> --finding <id> --fix-session artifacts/<baseline-run>/fix-session.json --json
```

What each step proves:

- `see` proves Screenslop captured a bundle for the current runtime surface.
- `critique` proves deterministic findings were derived from that bundle.
- `fix` proves Screenslop planned or applied a selected safe source patch.
- The second `see` proves there is fresh evidence after the patch.
- The second `critique` proves the fresh bundle was reviewed independently.
- `verify` proves the selected baseline finding is gone, still present, changed, or unknown by comparing baseline findings with fresh critique output.

`fix-session.json` is context, not proof. It can show what Screenslop patched, but only fresh capture plus fresh critique can support a `verified-fixed` claim.

For CI and agent contract checks, run the fixture-backed smoke flow:

```bash
npm run --silent smoke:e2e -- --fresh-mode fixed
```

That smoke uses copied fixtures and temporary source files. It proves command composition and artifact contracts; it does not prove a real app screen is visually fixed. Real UI claims still require runtime evidence from `screenslop see`.

For the first self-contained live simulator proof, run the real-runtime smoke:

```bash
npm run smoke:runtime
```

That smoke builds and launches `examples/runtime-smoke-app` through XcodeBuildMCP, captures Baguette-backed baseline evidence, applies one narrow source fix, rebuilds, recaptures, critiques the fresh bundle, and runs `screenslop verify`. It is still sample-app proof only. A user app needs its own real `screenslop see` capture on the user surface before any verified UI claim.

The runtime smoke prints JSON only. If Baguette, XcodeBuildMCP, the simulator, capture, fix, rebuild, or verification fails, it exits nonzero with the failing stage in the report.

Every report includes a compact `summary` block so agents do not have to guess
from raw stage logs:

```json
{
  "status": "passed",
  "captureStatus": "passed",
  "critiqueStatus": "passed",
  "fixStatus": "passed",
  "freshCaptureStatus": "passed",
  "freshCritiqueStatus": "passed",
  "verifyStageStatus": "passed",
  "verifyStatus": "verified-fixed"
}
```

Failure reports use the same keys with `failed` or `not-run`, plus `reason` and
`failedStage`. This is the part automation should read first. The full `stages`
array is still there when something goes sideways and you need the messy details.

The default smoke stays pinned to `examples/runtime-smoke-app`. A configured
target can be supplied for local dogfood without committing private paths. The
smoke runner resolves `--config` from the Screenslop checkout, so pass an
absolute private-app config path or a path relative to this checkout:

```bash
node scripts/smoke-real-runtime.mjs --config /path/to/private-app/.screenslop/config.json --identifier settings.saveButton
node scripts/smoke-real-runtime.mjs --workspace App.xcworkspace --scheme App --bundle-id dev.example.App --source-root App --identifier settings.saveButton
node scripts/smoke-real-runtime.mjs --config /path/to/private-app/.screenslop/config.json --identifier settings.saveButton --preflight-only
```

Configured target requirements:

- `workspacePath` or `projectPath`
- `defaultScheme`
- `defaultBundleId`
- `sourceRoot`
- `defaultSurface` or `--surface`
- a stable finding selector via `--identifier` or `--finding`

The smoke validates target config before build/run, keeps `verify` behind fresh
build, fresh `see`, fresh artifacts, and fresh `critique`, and redacts private
absolute paths in its final JSON report by default.

Use `--preflight-only` for private config checks. It validates the target and
prints redacted JSON without calling Baguette, XcodeBuildMCP, build/run,
capture, fix, or verify.

### `screenslop matrix`

Writes a bounded matrix report and one evidence bundle per matrix cell.

Each cell records `targetIdentity`, saying whether it can prove the build and the capture touched the same simulator. A cell builds through XcodeBuildMCP and captures through Baguette, and both used to resolve the simulator *name* independently — with duplicate names across installed runtimes, that can build one device and critique another.

The identity comes from XcodeBuildMCP's structured output (`data.artifacts.simulatorId`), not from scraping stdout for something UUID-shaped, which would happily pick up an unrelated identifier from diagnostics and hand it to capture as the device to photograph.

- `verified` — both ends reported the same device. The cell counts as `captured`.
- `mismatch` — they reported different devices. The cell **fails**.
Any cell that is not a proven capture makes the whole run unproven: `report.ok` is false and `matrix` exits non-zero when any cell failed or went unverified, or when nothing was captured. Counting only outright failures let five verified cells plus one unverified cell exit zero.

- `unverified` — the build reported no identity, so the two resolved a name independently. The bundle and its critique are still written and still inspectable, but the cell status is `unavailable` and it is **not** counted in `summary.captured`. A cell claims "built this, then captured it"; without both identities it cannot claim that, and an unproven target must not sit in the captured tally.

MVP usage:

```bash
screenslop matrix --dry-run --json
screenslop matrix --profile examples/matrix/default.json --json
screenslop matrix --profile examples/matrix/phone-sizes.json --critique --json
screenslop matrix --profile examples/matrix/phone-sizes.json --critique --design --agent-packet --json
screenslop matrix --critique --json
```

The built-in profile has six fixed cells:

- default configured iPhone
- large iPhone
- light appearance
- dark appearance
- normal Dynamic Type
- accessibility Dynamic Type

The packaged `examples/matrix/phone-sizes.json` profile is for non-interactive mobile-size checks:

- small iPhone: `iPhone 17e`
- normal iPhone: `iPhone 17`
- large iPhone: `iPhone 17 Pro`

Agents should use that profile when the task is “check this screen on small, normal, and large phones.” They should also run it before calling layout-sensitive UI work done: SwiftUI spacing, onboarding, paywalls, checkout, settings, full-screen flows, compact sheets, tab bars, scroll views, Dynamic Type-sensitive layouts, and any screen where a small or large phone could change the result. If those simulator names are not installed, run `baguette list --json`, copy the profile, and replace only the `device` values before capturing.

When `.screenslop/config.json` is missing, the report still keeps all cells and
marks them unavailable with no-config evidence bundles. With config present,
`--dry-run` writes the same cell bundles without runtime capture. Live capture
builds and launches the configured target through XcodeBuildMCP, then captures
with the configured `defaultSurface`, `defaultBundleId`, and default/device cell
preference. `--critique` runs deterministic critique after a successful cell capture. `--design` also runs the design-review layer after each successful cell critique, records per-cell design summaries, and writes matrix-level design consistency notes. Use `--agent-packet` with `--design` when each cell should emit a packet for agent judgment.

Baguette's farm page can sit beside matrix work as a live multi-simulator dashboard. Start it with `baguette serve` and open `http://localhost:8421/farm`; see `docs/baguette-farm.md`. Screenslop does not ship a `--open-farm` command, and the farm does not replace the matrix report or evidence bundles. Agents do not need the farm for headless checks; these are the non-interactive paths:

```sh
screenslop matrix --profile examples/matrix/phone-sizes.json --critique --json
screenslop matrix --profile examples/matrix/phone-sizes.json --critique --design --agent-packet --json
```

The matrix profile is JSON with `schemaVersion: 1`, `name`, and `cells[]`. Each
cell can set `id`, `label`, `device`, `appearance`, `dynamicType`, and optional
`surface`. Appearance and Dynamic Type now have explicit per-cell
`settingStatus` entries. The status is `not-requested`, `unavailable`,
`requested-only`, or `applied`.

In this MVP, runtime forcing is not shipped yet, so captured cells with requested
appearance or Dynamic Type report `requested-only`. No-config and dry-run cells
report requested settings as `unavailable`. The report must not pretend a cell
actually applied a setting just because the profile asked for it.

### `screenslop watch`

Future live loop for iterative design work.

This is where Baguette's stream view can become extremely useful.

## Short version

```text
setup     detect project metadata
doctor    check runtime and CLI freshness
self-update update the global CLI after confirmation
init      connect the project
learn     understand the design system
see       capture evidence
critique  find issues
fix       patch selected issues
verify    prove the fix
matrix    bounded device/settings stress report
watch     live iteration loop
```

## JSON examples and schemas

Agent-facing examples live in `examples/json/`:

- `see.json`
- `critique.json`
- `fix.json`
- `verify.json`
- `matrix.json`

Schemas live in `schemas/`. The matrix report contract is
`schemas/matrix-report.schema.json`.

## Design Intelligence command boundary

Design Intelligence is split between shipped profile learning and shipped design-review plumbing. These critique flags are opt-in:

- `critique --design --json`: opt-in design pass after deterministic critique.
- `critique --design-profile <path> --json`: profile override for a design pass.
- `critique --design --agent-packet --json`: packet writer for a coding agent or local reviewer.
- `critique --import-design-findings <path> --json`: import path for agent-produced design findings.

The deterministic `critique` command remains the default. A design-aware pass must preserve the measured finding schema and add optional fields only: `kind`, `proofLevel`, `requiresHumanReview`, `profileRuleId`, `judgment`, and `alternatives`.

`learn` profile refresh is not proof. If a profile is stale, run a dry-run refresh, review the delta, then write only with explicit confirmation. Design findings should use `design`, `product-logic`, or `profile-gap`; measured findings use `measured` and keep the existing fresh-bundle `verified-fixed` semantics.
