CLAUDE.md · git:20261001.0f54c50 · 2026-10-01 · sha256 83e4aadd0f110be5

CLAUDE.md git:20261001.0f54c50A

Immutable. This exact content never changes and is served at /api/v1/blob/83e4aadd0f110be5.

# CLAUDE.md — Reticle

> Master rules for this codebase. Read this first — the non-negotiables, layout, and conventions.

## What Reticle is

Reticle is the **proof layer for AI agents** — it verifies a running web app from the inside, without screenshots. The app embeds a dev-only SDK that instruments the DOM, network, routing, console, animations, and framework state; a local bridge + MCP server exposes that as structured tools the agent uses to **look, act, observe, and assert**. See `plan/` for the full design (gitignored).

## Monorepo layout

```
core          @reticlehq/core         — bottom-of-graph foundation: wire contract, constants, zod schemas (deps: open-verification, zod)
open-verification   open-verification  — the Open Verification Protocol: vocabulary, rules, `Realm`, `adjudicate()` (deps: zod)
engine        @reticlehq/engine       — the rules that decide a verdict, with no browser, daemon or CLI attached
conformance   —                       — drives the protocol's own scenarios against an implementation (PRIVATE, not published)
adapters/realm/browser       @reticlehq/browser      — instrumentation SDK embedded in the app (DOM-side)
server        @reticlehq/server       — bridge + MCP server, the `reticle` CLI (Node-side)
init          @reticlehq/init         — project scaffolder: `reticle init`'s codemod, no runtime (Node-side)
adapters/framework/react  @reticlehq/react        — React adapter: DOM ref -> component -> source file
adapters/build/vite       @reticlehq/vite-plugin  — Vite integration: stamps source + auto-injects connect()
adapters/build/babel-plugin  @reticlehq/babel-plugin — stamps data-reticle-source (source mapping, React 19)
adapters/build/next       @reticlehq/next         — Next.js source mapping (keeps SWC) via withReticle (CJS)
adapters/realm/electron   @reticlehq/electron     — Electron main-process adapter (IPC observer, capture)
adapters/realm/cli        @reticlehq/cli-realm    — realm for a command-line subject; spawns and watches it, no code inside (Node-side)
adapters/realm/tauri      reticle-tauri           — Tauri capture backend (RUST — outside every JS gate)
spec-runner          @reticlehq/test         — spec runner + matchers for CI (peer vitest)
adapters/lint/eslint  @reticlehq/eslint-plugin — dev-only lint rule: state changed ⇒ signal fired
apps/bench-app         @reticlehq/bench-app    — integration proof (Vite + React) AND the primary benchmark target
apps/api               @reticlehq/api          — support infra: backend the web e2e battery drives against
apps/next-smoke        @reticlehq/next-smoke   — integration proof: Next.js 15 App Router, RSC, SWC source mapping
apps/electron-smoke    @reticlehq/electron-smoke — integration proof: real Electron app (Vite + React renderer)
apps/electron-vue-pinia —                       — integration proof: electron-vite + Vue + Pinia (the #723 path)
apps/tauri-smoke       @reticlehq/tauri-smoke  — integration proof: real Tauri v2 app (Rust commands via invoke)
apps/e2e                @reticlehq/e2e          — support infra: the web e2e test runner itself
apps/atlas             @reticlehq/atlas        — adversarial fixture: one realistically-sized app, emergent defects
apps/large-dom-bench   @reticlehq/large-dom-bench — benchmark target: non-virtualized grid, vanilla (non-React) TS
apps/vibe-builder-demo @reticlehq/vibe-builder-demo — product demo: AI app-builder with Reticle as the QA layer
apps/examples          —                       — integration proof for frameworks with no smoke app (Remix, Astro)
install/               — the one-line installers: install.sh + install.ps1, served from reticle.sh
break/                 — adversarial harness: 26 hostile environments + whole-machine profiles (never shipped)
docs/                  — user-facing docs (getting-started, usage, token-efficiency, local-registry)
SKILL.md               — PUBLIC skill for users integrating Reticle into their own project (the canonical paste-URL)
plan/                  — research/design docs only, no code (ALWAYS gitignored)
```

**Three directories carry the three stages of getting started, and they are easy to confuse.** `install/` puts the CLI on a machine (Installation). `init/` is the package that wires Reticle into a project (First run). `server/src/command/setup/` is the runtime phase that boots the app and drives it — unrelated to the old top-level `setup/`, which is now `break/` for exactly that reason.

This is **one git repo** at the root (pnpm + turbo monorepo). The TS library packages are strict TypeScript; `@reticlehq/babel-plugin`/`@reticlehq/next` are plain CJS tooling, `adapters/realm/tauri` is Rust and invisible to every JS gate, and everything under `apps/` is a local fixture — all excluded from the build/lint/test gates. See [`apps/README.md`](apps/README.md) for what belongs under `apps/` and why.

## Service boundaries (who owns what)

- **`@reticlehq/core` is the contract.** Any message that crosses browser ↔ bridge ↔ agent is defined there as a constant + zod schema. It sits at the bottom of the graph: everything depends on it, and it depends only on `zod` and on `open-verification` — the protocol whose vocabulary the contract is written in. That second dependency is the floor, not a layer: open-verification itself depends on `zod` alone, so the graph stays acyclic. This line used to read "deps: `zod` only … it depends on nothing", which stopped being true when the protocol was extracted and nobody updated the sentence. Never inline a wire string in `browser` or `server` — add it to `core`.
- **`@reticlehq/browser` only touches the DOM/page.** It never imports Node APIs.
- **`@reticlehq/server` only runs in Node.** It never imports DOM APIs.
- **`@reticlehq/init` is build-time only.** It writes files and shells out to a package manager; it never opens a socket, reads daemon state or emits an event, and it imports NOTHING from `@reticlehq/server`. Everything it cannot know for itself — the release version, a tracer, the outcome reporter, the bridge pairing token, the declared install channel — arrives through the injected `InitHost` on `InitIo`. A new outward need is a new member on that interface, never an import.
- **`@reticlehq/react` is optional enrichment.** Core must work without it.

## Non-negotiable rules

1. **Equality:** `===`/`!==` always. `eqeqeq` is an error.
2. **No `any`.** Use `unknown` + zod narrowing at boundaries. `no-explicit-any` is an error.
3. **No free strings.** Every domain/wire/UI string is a named constant.
4. **No non-null `!`.** Use optional chaining + explicit null checks.
5. **Tests first.** RED → GREEN → REFACTOR.
6. **1000-line file cap**, on shipped source. Over it = a cohesion failure; split before adding. (Raised from 600: the old cap was forcing splits of genuinely cohesive units — a stateful class, a package's public-API barrel + bootstrap — and turning a two-line fix into a refactor. Cohesion is still the actual rule; the number is only the backstop.) `eslint.config.mjs` exempts `*.test.ts`, `apps/`, `bench/`, `break/` and `test/`, with its reasons written beside the rule: a fixture catalogue is long for a different reason than a god file is. This line did not mention the exemption, so a reader auditing rule 6 against the tree finds five test files over the cap and no way to tell a decision from a violation.
7. **Inject the clock.** Never call `Date.now()`/`Math.random()` inside pure logic — pass them in.
8. **Scope every data access to the authenticated principal.**
9. **Design tokens are the only place design values live.**
10. **Telemetry is part of the feature, not a follow-up.** Adding a tool? Put it in `TOOLS` and, if it produces a verdict, in `VERDICT_TOOLS` (`server/src/surface/tools/feedback-tools.ts`). Adding a finding kind? Add it to core's enum and never re-list it locally. Adding a dispatch path that bypasses `runTool`? It is invisible until you give it a reporter. Telemetry fails SILENTLY — nothing throws, no test reddens, the data is just permanently gone — so the rules are enforced by `telemetry-contract.test.ts` and written down in [`docs/telemetry-contract.md`](docs/telemetry-contract.md). Read that before touching anything that emits.
11. **No internal tracking tags.** Comments, file names, directory names, and test descriptions must never contain design-doc reference codes (letter + digit patterns like `N5`, `G4`, `M8`, `P2`, `F1`, `R1`) or internal version strings (like `0.3.7`).
12. **No field numbers in anything we ship.** Telemetry tells us things about the people using Reticle, and those numbers do not belong in the changelog, in a pull request, in a code comment, in the docs or in a commit message. They live in the gitignored `plan/`. A shipped comment gave the median tool-call count of one session type and the share of all bugs it found: true, useful to us, and nobody's business in a source file somebody installed. This is not guarded and cannot usefully be: a regular expression cannot tell a measurement of OUR benchmark from a measurement of SOMEBODY'S usage, and the numbers from the bench, the test suite and the install gate are welcome and load-bearing. Ask where the number came from. If the answer is "from users", it stays in `plan/`.
13. **A new guard needs a defect that actually shipped.** The guard tests — checks about the repository rather than about the product — had their yield measured this release: four ticks of guard-tightening found real but marginal things, while the first ninety seconds of CI found the defect that would have stopped anybody cloning the repo. They are cheap to write, they never fail in a way that teaches you anything new, and they accumulate, so the bar is now a NAMED incident: a guard lands citing the commit, issue or field report of the thing it would have caught, in its own header. "This could drift" is not a defect. Two of the three defects CI found this release were in code that already had a guard pointed at it, and both guards passed — `layout-map-is-real` asserted a path existed, `first-load-size` asserted a bundler was found, and existence was never the question. A guard that cannot state what went wrong is a guess about the future written somewhere that costs everybody time forever. Prefer deleting a guard whose incident has been made structurally impossible, and prefer fixing the ONE shared function over writing a guard that watches its callers. This rule is deliberately not itself guarded.

## Naming conventions

| Thing | Convention | Example |
| --- | --- | --- |
| Package | `@reticlehq/<kebab>` | `@reticlehq/browser` |
| File | kebab-case | `ring-buffer.ts` |
| Type / class | PascalCase | `RingBuffer`, `ReticleEvent` |
| Variable / function | camelCase | `pushEvent` |
| Constant object | PascalCase + `as const` | `EventType`, `ActionType` |
| React component file | PascalCase or `create-` prefix for creation flows | `App.tsx`, `create-session-view.tsx` |
| `useX` function | ONLY if it calls React hooks | else use `apply/build/get/handle` |

## Pre/post-coding checklist

> The routing below is also a table in [`docs/gates.md`](docs/gates.md) — that file is the one a human contributor reads, and it carries the "what is each gate blind to" column. If you change a gate's cost or scope, change it in both; they are the same rule stated twice on purpose, because one of the two audiences never opens the other file.

**Before coding:** scan for existing code to reuse → identify the constants you'll need and add them first → write the failing test. **After coding:** refactor with tests green → check the file is still cohesive (and under the 1000-line backstop) → run **`pnpm verify`** (which is `format:check && lint && typecheck && test:unit`) **before committing, not chained after it** → confirm no `any`, no free strings, no `console.log`.

**Run it as `pnpm verify`, not as four separate lines.** The `&&` is what makes a failure stop the run: pasted as separate commands, or chained with newlines, only the LAST one decides the exit code, so a red `lint` in the middle reports success. That is not hypothetical — it happened while writing this line, and the "all green" it produced was a lint error in code written seconds earlier.

`format:check` is first because it is the one CI enforces that **`pnpm lint` does not run**. This checklist omitted it until 2.6.0, which is exactly how a release branch with all four heavy gates green locally still turned CI's `verify` red on six prettier warnings — and `verify` is a dependency of `e2e`, `desktop-e2e` and `install-gate`, so one unformatted file skips every expensive gate behind it. `pnpm format` writes the fixes.

**Need quick product feedback?** Run `pnpm test:smoke` (one-time prerequisite: `pnpm exec playwright install chromium`). It builds the server dependency closure and checks real stdio MCP → daemon → Chromium → SDK → positive and negative verdicts. Evidence is in `artifacts/smoke/`; it does not replace the install, full E2E, or desktop gates. CI qualification is pending because the restricted local workspace cannot bind loopback ports.

The web and desktop batteries use private daemon state and bridge port `14400` by default (`RETICLE_PORT` overrides it). Framework integration uses `15400` (`RETICLE_INTEGRATION_PORT`); conformance uses `15401` (`CONFORMANCE_BRIDGE_PORT`). Occupied fixture ports are refused; cleanup only stops recorded child processes. A developer's daemon on `4400` can remain running.

**Touching the tool surface, the wire contract, or an observer?** Also run `pnpm test:e2e` (boots api + bench-app + next-smoke — **~8 min**, re-measured 2026-09-11 at 39 specs on an M-series mac; it was documented as "~20 min" for months and then as "~70s", and both numbers stopped anybody from planning around the real one). The unit gate cannot see cross-package drift: a tool rename once left four e2e specs dead across a whole framework and nothing caught it, because the battery is not part of `test:unit`. `e2e-surface-drift.test.ts` now catches the name-lookup half of that in the fast gate; the rest still needs the battery.

**Touching telemetry, feedback, or anything that emits an event?** Read [`docs/telemetry-contract.md`](docs/telemetry-contract.md) first — short, and the difference between a metric that works and one that is silently absent for six months. `pnpm test:e2e` runs `telemetry-events-test`, which fires all ten event kinds against a real capture endpoint and asserts each one lands. It exists because telemetry fails SILENTLY: `daemon_stopped` was emitted fire-and-forget just before `process.exit(0)`, so the POST died every time and nothing threw, nothing failed, and no unit test could see it. Half the spec is leak checks — a telemetry mistake is silent, shipped, and about somebody else's data.

**Touching `open-verification`, the adjudicator, `WebRealm`, or anything a verdict is derived from?** Also run **`pnpm gate:conformance`** (~3 min). It drives the published specification's own scenarios against this implementation on a real browser AND a real Electron shell, and every verdict is decided by the spec's `adjudicate` rather than by Reticle's kernel — scoring an implementation against its own rules makes every implementation conformant by construction. It gates REGRESSION, never the score: `earned` stays `none` until the fixture grows, and `--gate` fails only when a scenario that COULD be planted was driven and answered wrongly. It is deliberately not a spec inside `pnpm test:e2e`, because the battery boots the demo API with `REFLECT_MS=6000` and a conformance run folded into it would score every scenario against a deliberately-slowed backend while still printing a number. See [`docs/gates.md`](docs/gates.md).

**Touching desktop — `@reticlehq/electron`, `adapters/realm/tauri`, the IPC observer, or desktop capture?** Also run `pnpm test:e2e:desktop`. It starts two real Electron main processes (plain Vite + electron-vite) and a **packaged** Tauri binary and drives them headless (~3 min, most of it the Rust build). The web battery boots three HTTP servers and no desktop runtime, so it is blind to all of this — which is how v2.3.0 shipped Electron and Tauri support with no automated coverage at all, and why a `no-visual-provider` lie on concurrent captures lived in code that no gate touched. `adapters/realm/tauri` is Rust and outside every JS gate; CI's `rust` / `rust-macos` jobs are the only thing that compiles it.

**Touching `reticle init`, `@reticlehq/vite-plugin`, `@reticlehq/next`, or anything a user runs before their first session?** Run **`pnpm gate:install`** (~15 min). Nothing else in this repo can see the install: every app in `apps/` is already instrumented, so re-running `init` over one reports "already wired" for every step and proves nothing — which is exactly how v2.3.0 shipped a Next.js install that connected **0% of the time** through three independent defects, none of which any check short of opening a browser could see.

The gate scaffolds thirteen pristine apps — `vite-react`, `vite-vue`, `vite-vanilla`, `next-app-router`, `next-pages-router`, `monorepo-subdir`, `astro`, `sveltekit`, `cra`, `nuxt`, `react-router`, `remix` and `angular` — and publishes this checkout to a local Verdaccio so `init` does its own dependency install, boots each one, opens it in a real browser and polls for a session. It asserts **zero `⚠`** and diffs `init`'s plan against `apps/e2e/install-baseline.json` — because a step silently changing mark, or vanishing from the plan, passes every other check. Changed the plan on purpose? `pnpm gate:install --update-baseline` and commit the diff (add `--only <id>` to re-record one scaffold; the write merges, so it will not truncate the others).

Nuxt and React Router used to be deliberately absent from that list, because `init` handed both a MANUAL step instead of writing the connect file and so could never satisfy the zero-`⚠` assertion — React Router framework mode is where #678 happened. `init` now writes both files itself, which is what let them into the gate; the scaffold list above is the one place that says which paths are actually covered.

`pnpm gate:install:self-test` blocks the real Reticle socket and requires each scaffold to fail only its session check. Setup errors and inconclusive transport cannot pass the control; no scaffold is waived. CI runs it on gate-machinery changes and nightly/manual qualification. The combined `pnpm gate:install --with-self-test` shares a single publish between both phases. Positive installs must also answer true/false MCP assertions and a DOM action. CI packs the packages once in `install-packages` and tests the same SHA-checked tarballs in all cells; full Linux queue coverage is unchanged. Both Rust compile jobs now route on changes to their standalone Cargo tree.

That covers install **regressions**. It does not cover install **complexity** — a 70-dependency app with ten Vite plugins is a different question, and that still lives in the sibling **`reticle-fixtures`** repo (`node scripts/verify.mjs`), which installs into pristine upstream apps on a `clean` branch. Read [`docs/fixtures.md`](docs/fixtures.md).

**Accuracy outranks tokens, and the trade is not hypothetical.** A cheaper surface bought its saving with a lie: run as an arm of the fix-and-verify benchmark, the `lean` profile fixed 3/5 where the full surface fixed 5/5, and produced the first FALSE GREEN this repo has ever measured — six turns, 62k tokens, the cheapest cell of the run, ending in a hypothetical walkthrough ("spaces are trimmed, valid part is used. VERDICT: FIXED") against a submit button that was still enabled. It reasoned about its own code instead of driving it. So a token optimisation ships only with a correctness measurement beside it, and a verdict that lies costs more than every token it saved — the verdict IS the product. Cutting turns is different and welcome: the two instruction fixes this release removed 21% of the tokens with identical outcomes, because they changed the ROUTE to the answer, not the evidence behind it.

**Timing assertions are a bug.** Two flaky tests this build both asserted a DURATION when the invariant was a BOUND. If the property is "cost is fixed", assert the bound (output size, truncation flag) or use a generous per-test timeout — never `Date.now() - t < N`, which is a statement about the machine and fails only under parallel load, i.e. only in CI.