day8

re-frame2-pair

Pair-program against a **running** re-frame2 application via its Tool-Pair contract — attach to a live shadow-cljs nREPL, inspect a frame's app-db, dispatch events, hot-swap handlers, read the trace stream and per-frame epoch history, time-travel with `restore-epoch`, and replay a retained epoch strictly with `replay-epoch`. Use when the user is operating on (or wants to operate on) a live runtime they have running locally. **Do not use** for static spec reading, architecture questions, design discussion, or ordinary source edits when no runtime is involved — those belong to `skills/re-frame2/` (authoring) or direct spec reading. See `references/vocabulary.md` for the surface glossary; vocabulary matches alone do not justify activation.

day8 52 Updated 1mo ago

Resources

13
GitHub

Install

npx skillscat add day8/re-frame2/re-frame2-pair

Install via the SkillsCat registry.

SKILL.md

re-frame2-pair

Pair-program on a live, running re-frame2 application (a browser tab behind shadow-cljs watch): help the developer understand, debug, and modify it by operating on the live runtime, not just reading source.

Router skill. Trigger-time guard rails are below; operational depth (op catalogue, recipes, error handling, hot-reload protocol, v1-migration notes) lives in references/, loaded on demand.

One runtime — the browser heap on the other end of the nREPL connection. Every op this skill teaches or allow-lists reads and writes that heap and nothing else. Story variants are no exception: a variant is an ordinary frame in the same heap, so you enumerate and run it through eval-cljs over re-frame.story/* and then address it with the ordinary frame tools (references/stories.md). Never start or call a second MCP server mid-session to reach a runtime — a separate process has a separate registry and a separate app-db, so a keyword that exists in both is two different objects, and composing reads across them produces confident nonsense. Explicitly headless, no-browser Story work is a different task with a different host: route it to `tools/story-mcp/README.md` and the authoring skill, and don't run it from inside a live pair session.

The three primitives

Your agency runs through three coupled primitives, all part of re-frame2's Tool-Pair contract:

  1. The REPL — a shadow-cljs nREPL session connected to the browser runtime; ClojureScript forms evaluate against the real app.
  2. The trace stream — (rf/register-listener! :trace id cb) for live trace events; (re-frame.trace.tooling/trace-buffer frame-id) for the retain-N ring. (Listeners attach through the stream-parameterized facade verb — the 2-arg re-frame.trace.tooling fn behind it is an implementation seam, not a second spelling. trace-buffer, by contrast, IS a same-signature rf/ re-export, and it exists on BOTH platforms — in a cljs-eval form spell it fully qualified as re-frame.core/trace-buffer (or the tooling home), because the eval namespace carries no rf alias.) trace-buffer takes the frame-id first ((trace-buffer frame-id) cascade bundles, (trace-buffer frame-id opts) filtered) — a missing/destroyed frame-id silently returns [], so never pass an opts map as the sole arg. This skill registers exactly one trace listener (id :re-frame2-pair) so tools coexist.
  3. The epoch history — (rf/epoch-history frame-id) returns the per-frame ring of :rf/epoch-record values, each carrying the cascade's :db-before, :db-after, :trace-events, and the structured :sub-runs / :renders / :effects projections. (rf/register-listener! :epoch id cb) is the assembled-stream listener.

Every op eventually becomes a short ClojureScript form evaluated through the REPL, usually against a helper in the re-frame2-pair.runtime namespace the consumer app preloads (see §Setup).


Setup — preload re-frame2-pair.runtime

The skill's helper namespace ships into the app via shadow-cljs's :devtools :preloads mechanism (separate from Xray's devtools preload and true-inline [data-rf-xray-host] panel contract). The re-frame2-pair preload is required; there is no per-session cljs-eval inject fallback. When discover-app can't find the marker it runs a diagnostic ladder; the normal missing-preload verdict is :reason :runtime-loaded-but-preload-missing. See references/errors.md §discover-app preload-failure ladder for the full reason set and each recovery.

The skill installs from a repo checkout (not npm — see the README's Install / `docs/LOCAL_DEV.md`), so the preload comes off the linked skill's own preload/ directory. Point the consumer app's shadow-cljs :source-paths at it — the two-line shadow-cljs.edn change:

{:source-paths ["src"
                "<abs>/skills/re-frame2-pair/preload"]  ;; add this (the linked skill's preload/)
 :builds
 {:app {:devtools {:preloads [re-frame2-pair.runtime]}}}}  ;; …and this

The re-frame2-pair.runtime namespace is separate from the MCP server (@day8/re-frame2-pair-mcp) — the MCP server does NOT ship preload/, so wiring only the server leaves discover-app failing with :runtime-loaded-but-preload-missing. (Once the @day8/re-frame2-pair package is published to npm, npm install -D @day8/re-frame2-pair and point :source-paths at node_modules/@day8/re-frame2-pair/preload instead — see `docs/LOCAL_DEV.md`.)

Verify by running discover-app — success is {:ok? true :build-id ... :debug-enabled? true :frames [...]} plus other health slots. A missing preload returns {:ok? false :reason :runtime-loaded-but-preload-missing :hint "..."}; report the hint verbatim (fixable in seconds). The other ladder rungs (:build-not-running / :no-runtime-connected / :nrepl-unreachable, plus the blanket :runtime-not-preloaded degradation fallback) each mean a different fix — see references/errors.md §discover-app preload-failure ladder.

The epoch surfaces also need day8/re-frame2-epoch on the app's classpath, with re-frame.epoch required at boot. Neither the preload nor the MCP server brings it and discover-app does not check for it, so a core-only app passes discover-app, then every epoch read (watch-epochs, trace-window, (rf/epoch-history …)) comes back [] and restore-epoch / replay-epoch refuse — see references/errors.md §Tool-envelope refusals for its one loud symptom, :rf.error/epoch-artefact-missing.

Registering the MCP server takes a fresh session, not --continue. Wiring @day8/re-frame2-pair-mcp into the agent host (claude mcp add, or editing settings.json) only takes effect in sessions that start afterwards — a --continue'd session won't surface the tools even though claude mcp list reports Connected. Exit and start a new session after registering (also after rebuilding out/server.js).

The v1→v2 migration boot-smoke needs only a browser-console read, not this MCP — see `skills/re-frame-migration/references/runtime-smoke-test.md`. Reserve the full pair MCP for the deeper interactive debugging once the boot-smoke flags something.


Cardinal rule — two modes of changing the app

  • REPL changes (hot-swap a handler, evaluate a form, reset a frame's app-db) are ephemeral — survive hot-reloads of unaffected namespaces, lost on full page reload. Use for probes, experiments, throwaway fixes.
  • Source edits (Edit / Write) are permanent. Capture your probe's current value before the edit (that's the baseline); after the edit you must run the hot-reload coordination protocol (tail-build with the probe and that pre-edit baseline) before dispatching or tracing, or you'll interact with pre-reload code and get misleading results. The pre-edit baseline is what makes a fast reload — one that lands before tail-build's first sample — read as success instead of a spurious timeout.

Know which mode you're in and why. Strict source-edit protocol: references/ops.md §Hot-reload coordination.


Connect first, every session

Before any other op, run discover-app (mcp__re-frame2-pair__discover-app). It locates the shadow-cljs nREPL port, connects, switches the session to :cljs mode for the running build, verifies re-frame2 is loaded with interop/debug-enabled? true, and confirms the re-frame2-pair.runtime preload landed (see §Setup).

Connect once — the resolved build sticks. A successful discover-app records the resolved build as the session-sticky default on the connection, for every resolution path — auto-selected single build, explicit :build, and :port. After one discover-app, call every other tool (orient, read-dom, read-ui, snapshot, get-path, …) with no build arg and it targets the resolved build — even with several builds running. Pass build again only to switch builds (an explicit :build on any later call wins and re-sticks). The sticky default resets on nREPL reconnect (a shadow restart); the next discover-app re-establishes it.

Arg forms (don't guess). Each arg has one expected shape:

Arg Form Examples
build bare build id; a leading colon is also tolerated. Omit it when one build is running — discover-app auto-selects it "examples/standard-epochs" or ":examples/standard-epochs" — identical
port integer — the port from the browser URL 8031 — see Connecting from a URL below
frame / frames keyword with the colon ":rf/default", ":app/main", [":rf/default" ":rf/xray"]

Single-build auto-selection / connecting from a URL. You usually don't pass build — one running build auto-selects (several → it errors with the running-builds list, never a silent guess). When you know only the open tab's URL, pass discover-app {port: 8031}: the server reads the :dev-http map and resolves the build served on that port (no build maps to it → :reason :port-unresolved, no silent fallback; an explicit :build wins if you pass both). The port-resolved build sticks like every other path. discover-app reports every build/frame id as a full keyword in the canonical EDN — read the EDN text for the id exactly as you'd type it back into a :frame arg (the :structuredContent JSON view strips the colon, a documented lossy projection).

Read the :freshness token before you trust a read. Every :ok? true discover-app payload carries :freshness {:liveness <verdict> :hint <str> ...}. Pattern-match :liveness first: :fresh (read away), :stale-build (tab serving OLD code — the :hint names the URL to reload), :no-runtime (no live CLJS runtime — reads come back blank; :hint names the URL to reload then re-run discover-app), :unknown (build-worker state unreadable — not a green light; usual cause is a zombie shadow-cljs JVM, so the :hint steers to npx shadow-cljs stop → one watch → reload → re-discover).

You cannot reload a browser yourself — on a non-:fresh verdict, relay the :freshness :hint to the user as the single next step rather than firing reads that return blank.

On any failed precondition, discover-app returns structured edn ({:ok? false :reason :runtime-loaded-but-preload-missing} or another ladder rung: :build-not-running / :no-runtime-connected / :nrepl-unreachable). Report the failing check verbatim; do not guess workarounds. See references/errors.md for reasons + recoveries.

Port + build discovery are automatic. On the first tool call the server discovers the live shadow-cljs nREPL itself and absorbs shadow restarts transparently; the resolved build sticks. Only reach for manual resolution on :reason :port-unresolved (read the app's shadow-cljs.edn :dev-http yourself to correlate the URL port to its build, then pass discover-app {build: ...}). The full discovery cascade, build-id resolution rules, and the --port-file / SHADOW_CLJS_NREPL_PORT overrides live in references/mcp-transport.md — rarely needed.

The nREPL session persists between turns. A full page refresh drops the runtime, but the preload re-installs it on the next bundle load — no manual reconnect. Every op checks the load-time marker (js/globalThis.__re_frame2_pair_runtime) first; if missing, the op refuses with the runtime-side :runtime-not-preloaded hint pointing here. (That per-op check is distinct from discover-app's richer ladder, which on the same missing-marker condition reports the more precise :runtime-loaded-but-preload-missing — see references/errors.md.)

For a refresher on the MCP surface before the first real op, optionally call get-re-frame2-pair-instructions — inline onboarding text (six routing rules for which tool to reach for, then the EDN posture, tagged-mutation conventions, the wire pipeline) with no nREPL round-trip. It does not list the tools; tools/list already gave you every descriptor. Its routing preamble states the same eval-cljs rule as §Style guidance below — reach for a typed tool whenever one fits the gesture, and treat eval-cljs as first-class for the long tail no typed tool covers — so read the two as one rule restated, not as competing advice.


Orient before you drill

Your read order, every session: discover-app → orient → a targeted slice.

After discover-app connects, your first read is always orient (mcp__re-frame2-pair__orient {}) — never a snapshot. orient returns a compact, one-round-trip app-shape summary: liveness, the app frames (reserved :rf/* tool frames excluded), each app frame's top-level app-db keys only, registry counts, and the navigable event / sub / fx / machine ids. Bounded by construction — it hands you the map without pulling any bulk, and omits the :rf/xray tool frame's contents.

Only after orient do you drill — into slices, never whole frames:

You want Use
one sub's current value read-sub {sub: "[:cart/total]"}
one app-db path get-path {path: "[:cart :items]"}
a bounded sub-tree snapshot {path: "[:cart]"} (the snapshot tool defaults to :summary — top-level shape only; a path narrows it further)

Never read a whole frame to orient. A snapshot {path: "[]"} (full, unsliced) or an eval-cljs form that derefs an entire frame's app-db can be enormous — especially the reserved :rf/xray tool frame: its app-db is Xray's entire working set (the epoch ring with each epoch's :db-before / :db-after, the trace buffer, diff projections, panel state) and grows without bound. A full read of it has overflowed past 100K tokens in one call. You essentially never read the :rf/xray frame at all — pair against the app frame (whatever id the app registered, e.g. :app/main, or :rf/default if the app chose that).

Mental model: breadth-first shallow (orient) → depth-first narrow (read-sub / get-path / snapshot {path}). snapshot is a drill-in tool you hand a path, not the way you take in an app.


Multi-frame model — set the operating frame

re-frame2 supports multiple, named frames (Spec 002). Most apps run one app frame (registered at the root — whatever id the app chose, e.g. :app/main); larger apps run several. The public address is the frame (EP-0023) — image -> frame -> event stream: target a frame id in a single process-local frame-id space, no realm/container coordinate. Every read/write op resolves an operating frame through a four-tier cascade: per-call frame arg (tier 1) → session pin (tier 2) → the sole registered app frame (tier 3) → nil/ambiguous (tier 4). Tier 3 is unique resolution, not synthesis. Framework-reserved :rf/* tool frames (Xray's :rf/xray, SSR/stories slots) are excluded from the ambiguity count, so a single-app session also running Xray auto-resolves to its one app frame. (:rf/default, if an app registers it, is an ordinary app frame — no framework privilege, still counted.) Full detail: references/ops.md §Frames.

Set the session pin with the dedicated operating-frame tools — three MCP tools surface tier 2 directly, no eval round-trip: set-operating-frame {frame: ":foo"} (pin — the escape from the tier-4 refusal; validates the id, returns the {:frames :selected :operating} triple), reset-operating-frame {} (clear), get-operating-frame {} (read; :operating nil means ambiguous). These three are NOT subject to the :ambiguous-frame refusal — they resolve it.

When the operating frame is ambiguous (two-plus app frames, no pin), every other frame-targeted op refuses with :ambiguous-frame rather than guess — a write into the wrong frame is unrecoverable without restore-epoch. Reads refuse too (the validated read helpers return :reason :ambiguous-frame rather than silently reading :rf/default). Mirrors Spec 002 §Frame presets / lifecycle convention.


Where the depth lives — loading map

Read the leaf matching the task. Most references are ≤250 lines; the two catalogue leaves (ops.md, recipes.md) run longer. Screen reads — what is on screen and why it rendered — are their own leaf (screen-reads.md), so an ordinary read/dispatch/trace session never loads them.

Task shape Reference
Pick a structured op (read, validated sub-read, write, dry-run, trace, signal recording, blocking wait, hot-reload, time-travel) references/ops.md
Pick a screen-read op (DOM source bridge, read-dom vs read-ui, Fresco evidence) references/screen-reads.md
Orient on an unfamiliar app in one call, or read a single sub's validated value orient / read-sub — see references/ops.md §Read (arg signatures in references/mcp-transport.md §MCP tool reference)
Find out which registrations / image a specific frame actually runs (the multi-image / Story case where the same id resolves differently per frame), with per-frame provenance describe-image — see references/ops.md §Frames
Read what's actually on screen (rendered content + producing view entity) read-ui / read-dom — see references/screen-reads.md §View → rendered content
Ask which Fresco boundaries are mounted right now (read-mounted-boundaries), or which boundaries read a subscription you can name (read-read-attribution). A boundary here has no view name — the runtime mints no identity, so it is keyed by its read set. Reagent and UIx apps answer :evidence-tier-unavailable on all three; every other op in this skill works on them unchanged see references/screen-reads.md §Fresco evidence
Ask which of a boundary's reads moved when it rendered and you didn't expect it to (explain-render) — the proven half, beside retained runs offered as leads see references/screen-reads.md §Fresco evidence
Simulate an event's consequence WITHOUT committing it (no fx fire) dispatch-dry-run — see references/recipes.md §"What would this event do?"
Record signals while the human interacts, or block until a condition lands record / read-recording / watch-until — see references/ops.md §Signal recording
Run a named procedure the user asked for ("why didn't my view update?", post-mortem, experiment loop, etc.) references/recipes.md
Drive a Story variant in the app you have open — enumerate the browser's own registry, run a variant, then read/dispatch/trace/diff it as an ordinary frame (the variant id is the frame id) references/stories.md
Decode a deduped wire payload (:rf.mcp/dedup-table) or pick the right size-conscious arg (max-tokens, path, mode, dedup, elision, limit/cursor, cache, max-entries) references/wire-size-budget.md
Translate a structured {:ok? false :reason ...} to plain English; suggest the recovery references/errors.md
Edit source, then wait for the browser to pick up the new code references/ops.md §Hot-reload coordination
Map a v1 (re-frame-pair) surface to its v2 equivalent (or know it has none) references/ops.md §Dropped from v1
Install/configure the persistent-connection MCP server references/mcp-transport.md

Load at most two references for a single task. Wanting three means the request spans concerns and should be broken up.


Style guidance

  • Read before you write — orient first, then drill into slices. Your first read each session is orient, never a whole-frame read; ground a hypothesis by drilling into a slice (read-sub / get-path / snapshot {path}). See Orient before you drill for the rule and the :rf/xray overflow.
  • Prefer a structured op when one FITS the gesture; eval-cljs is the workhorse for the long tail, not a last resort. Dedicated tools (orient, read-sub, get-path, read-ui, dispatch, …) give a validated, elided, single-round-trip answer for the gesture they own; epoch forensics, arbitrary-selector DOM reads, cross-referencing, and recovery are first-class eval-cljs work — see recipes.md §eval-cljs is the workhorse. (Privacy caveat: raw eval-cljs is un-elided — see the privacy bullet below.)
  • Hypothesis-test through dispatch-dry-run, not a live dispatch or throwaway eval-cljs handler. For "test this handler" / "try a dispatch" / "what would happen if…", reach for dispatch-dry-run first: it runs the whole cascade (reducer, interceptors, schema, machine transitions, sub-runs, renders) without committing — no fx execute, the framework auto-rolls-back app-db. It is the safe primitive for this gesture; dispatch and raw eval-cljs are not. Dry-run targets a registered event, so to probe a throwaway handler: register it with eval-cljs ((rf/reg-event :exp/probe …)), then dispatch-dry-run {event: "[:exp/probe …]"} — the rollback still applies, leaving no trace. See recipes.md §Experiment loop and §"What would this event do?".
  • WARNING — a reg-event handler returning {:db <bare-map>} REPLACES app-db wholesale (does NOT merge), so a throwaway probe driven by a live dispatch nukes the entire frame's app-db, unrecoverable without restore-epoch. Two safe paths: prefer dispatch-dry-run (rolls back), or if you must commit return {:db (assoc db …)} from the live db cofx — never a bare literal map. Full treatment: recipes.md §Experiment loop.
  • Keep it in re-frame2's vocabulary. Dispatch, reg-event, reg-sub, reg-machine, frame, epoch — speak the app's language. Avoid reset! of a frame's app-db except when surgically needed, and say so when you do.
  • On ambiguous/failing tool resolution, read the project config (shadow-cljs.edn, deps.edn) rather than bouncing off the tool. "Which build is this port?" / "where does this artefact live?" usually sits in the source config in plain sight.
  • Experiment, don't speculate. When an answer isn't obvious, probe at the REPL against live data.
  • Validate before proposing. Compose a hot-swap/suggestion form and run it against current state first.
  • Narrow detail as you go. Summaries first; drill into a specific epoch, diff, sub-run, or render entry on request.
  • Always resolve UI references to source first. When the user mentions a button, view, panel, or "the thing I clicked", run dom/source-at before speculating. Reporting re-com/button at app/cart/view.cljs:84 grounds the conversation in a file the user can open; "probably the Save button somewhere in the profile view" doesn't.
  • Surface restore limits. Before any time-travel experiment, walk the cascade's effects and tell the user which effects already fired and cannot be reversed.
  • Use the assembled epoch stream by default; reach for the raw trace stream when you need detail the projection drops. :sub-runs, :renders, :effects are the routing surface; :trace-events is the escape hatch for detail the projection omits (e.g. per-interceptor timing, or the raw fx :args the :effects projection redacts off-box).
  • One trace listener per skill. This skill registers one listener (:re-frame2-pair) and one epoch listener (:re-frame2-pair-epoch). Multi-tool coexistence is the default — don't worry about other listeners; per Spec 009 §Listener ordering, ordering is not contract.
  • Structured MCP reads elide by default; raw eval-cljs does NOT. Per Spec 009 §Privacy, the structured read tools (snapshot, get-path, read-sub, trace-window, watch-epochs, dispatch-dry-run, and the recorders) force wire-boundary elision server-side under the --allow-sensitive-reads gate (OFF by default) — sensitive → :rf/redacted, large → :rf.size/large-elided, epoch records additionally route through project-egress (the one record-level egress door, which recognises an epoch record by its stamped :kind). So structured reads are safe to fire by default. But eval-cljs is default-ON (governed only by --no-eval) and NOT governed by this gate — it returns the form's value without running the elision walker, so a raw (re-frame2-pair.runtime/snapshot) / (rf/epoch-history …) can ship verbatim app-db / trace / epoch state — secrets included. This is the raw-eval carve-out: don't reach for raw eval to read a sensitive path / sub / trace / epoch when a structured tool fits — reserve it for forensics, cross-referencing, and recovery. Full mechanism in references/vocabulary.md §Privacy posture.
  • Route named writes through the dedicated, gated tools. Time-travel undo and state injection have dedicated tools — restore-epoch and replace-app-db — both allow-listed and the canonical path (validate, append a synthetic undoable epoch, tap>, return a :cascade-summary). Both are gated by the server's --allow-writes flag (default OFF); the server's gate, not the allow-list, is the write boundary. Raw eval of a write form ((rf/restore-epoch! …) / app-db-reset!) is the backstop, not the default — reach for the dedicated tool first. Full detail: references/ops.md §Time-travel.
  • Replay a retained epoch through replay-epoch, never by re-typing its payload. To re-drive a recorded event faithfully, pass the epoch id alone: the runtime resolves the raw record in-process and re-dispatches it under :strict with the recorded :rf.cofx and both recorded override maps, so the generator is never consulted and a fact the record lacks fails loud (:rf.error/missing-required-cofx). The dispatch {replay: true, cofx: …} recipe needs payloads the projected epoch pages only ever show as :rf/redacted. Replay does not restore first (rewind with restore-epoch when the start state matters), fires effects again, and records a new epoch. It carries dispatch's authority — not --allow-writes-gated. Full detail: references/ops.md §Time-travel.

When to also open Xray

A re-frame2-pair session and a running Xray panel are complementary surfaces over the same trace bus + epoch history. re-frame2-pair owns the driving (dispatch, hot-swap, restore-epoch); Xray owns the seeing (visual reading of what just happened across its Dynamic event-spine tabs and Static registry-browse tabs — skills/re-frame2-xray/ is the canonical source for Xray facts). Reach for Xray alongside re-frame2-pair when:

re-frame2-pair just did Open Xray to …
Rewound to an earlier epoch via restore-epoch Scrub the bottom-rail time-travel scrubber to inspect adjacent epochs visually; pin slices via the app-db tab's inline diff.
Dispatched into a cascade you don't fully understand The Epoch tab lands on the latest cascade and shows the dispatch-id tree.
Hot-swapped a sub or reg-event handler Watch the Epoch SUBSCRIPTIONS step recompute the invalidation chain (:cart/total ← :cart/items ← [:cart :items]).
Stepped into a machine transition Open the Machine tab / Machine Inspector for the state-chart view with transition history.
Triggered a schema violation Schema violations surface inline on the Epoch tab (and as an L2 pink-wash) with recovery mode + source coord.

Authoring-side guidance for getting Xray mounted (preload, layout host, suppress-auto-open knob, popout, host-CSS-variable resize) lives at `skills/re-frame2/references/tooling/xray.md`. When advising a user mid-session on which panel to look at, route them there for the mount-side detail; this skill stays focused on the driving side.


Deep-dive content (full API reference, EP design rationale, spec corpus, migration guide) routes through `SKILL-REDIRECT.md` at the repo root. Full skill-disambiguation matrix (when to use which skill) lives at `skills/README.md` §Skill routing — single source.