* feat: support --settle on scroll and back (#1638) Scroll-then-observe and back-then-observe are legitimate agent pairs, but the post-action observation registry never grew past the touch commands, so `--settle` on either was rejected with INVALID_ARGS — burning a tool call each in AppControlBench's bsky-16. Both commands now carry the `settle` descriptor trait, and every surface derives from it rather than a hand list: CLI allowed flags, MCP/SDK input fields, the flag-sourced timeout envelope, and MCP ref-pinning. The CLI flag/metadata helpers moved out of the interaction family into post-action-observation-grammar.ts (back is a system command), and SETTLE_REF_ISSUING_TOOLS became a derivation — a hand list would have silently stopped pinning the new commands' refs. settleAfterInteraction and the new settleObservationCommand are two entry points over one engine: same loop, storage, hints, and diff bounds, with the target-less path supplying its own baseline and no proximity point. The daemon reaches that command through the runtime surface, never by importing `commands/` (R2) — the same seam the touch handlers use for press/fill — and generic-settle.ts is loaded through a lazy `await import` returning a closure, so the interaction runtime subgraph stays out of this dispatcher's static graph (a static edge folded ~18 files into the daemon-server type cycle; R10 caught it). Both of generic-settle's orderings are load-bearing and tested: the baseline is frozen before dispatch (and before the Android dialog preflight), and the observation runs after markDeferredInteractionOutcome so settle's first capture folds in the #1542 stabilization rather than racing it. The ADR 0014 "a settled diff publishes refs" rule moved to settle-ref-issuance.ts, shared by both routes. One divergence is deliberate: scroll/back resolve no element, so the diff baseline is the session's STORED pre-action tree — "settled tree vs the last tree you observed" — not press's freshly resolved pre-action capture. Both commands also switch to preserve-daemon on timeout, which changes the non-settle path too: with --settle their dominant hang mode is now a wedged accessibility bridge, and a timed-out capture must not reset the daemon and lose every session (#1105). The reviewed-set gate records it. Live-validated on an iOS 26.2 simulator (Settings): scroll --settle settled in 1786ms with a +6/-6 diff carrying fresh refs; back --settle in 771ms with +15/-6. Alternating cost runs, one call vs the pair it replaces: scroll 2.9-3.0s vs 5.3-5.6s, back 3.1-3.2s vs 4.7-5.1s. Those include the #1627 deep-capture extension. * fix: render settled-diff refs paste-ready in CLI output A settled diff activates a PARTIAL ref frame (ADR 0014), which admits only the pinned `@eN~s<gen>` form of the refs it issued. The unchanged-interactive tail already rendered that way, but the diff's own added lines rendered the bare `@eN` embedded in the snapshot line — so a CLI caller who copied the ref the diff just handed them got `plain_ref_requires_complete_frame` and had to append the generation by hand. Added lines now render pinned when the response carries `refsGeneration`, exactly like the tail. Removed lines render verbatim: they name elements that just left the screen, and `SettleDiffLine` never gives them a ref. This is not new to scroll/back — press/click/fill/longpress had the same gap since #1101. MCP was never affected: its ref-pin store rewrites plain refs on the way in, which is why the model never sees a suffix. Live: `scroll down --settle` now emits `+ @e14~s218078 [cell] "Game Center"`, and `press @e14~s218078` copied straight out of that line taps successfully. * test: record the pinned-diff-ref bytes in the output-economy baseline Rendering added diff-line refs pinned costs 8 bytes in the two settle CLI text samples (two `~s<gen>` suffixes). The output-economy baseline is the tripwire for exactly this, so the increase takes an explicit reviewed waiver rather than a silent baseline bump — the same one the settled TAIL's pins already carry, for the same ADR 0014 reason. Only `bytes` moves: lines, refs, hints, and shape are unchanged, which is the evidence that this is a suffix on existing refs and not a new payload. Caught by CI, not locally: `pnpm test:unit` runs unit-core and subprocess-stub only, while the Coverage lane runs every vitest project. * test: prove the generic settle degrades when its runtime cannot be built `createGenericSettleRuntime` catches and returns undefined so an observation that cannot even start does not fail an action that already succeeded. That was a claim in a docstring with nothing behind it — the one changed line the coverage gate reported uncovered (95/96). The test puts the session in the state the catch exists for: the router handed us a session that is no longer in the store, so building the settle runtime throws SESSION_NOT_FOUND. The response keeps its scroll result and simply carries no settle payload. Removing the try/catch fails it. * build: teach fallow that vi.mock reaches pinOwnProcessStartTime dynamically Not from this PR: #1642 added `pinOwnProcessStartTime` on main, and its three consumers reach it the only way a Vitest module mock can — `vi.mock(path, async (importOriginal) => (await import('...')).pinOwnProcessStartTime(...))`. Dependency analysis cannot follow that dynamic import to a consumer, so the export reads as dead the moment any PR pulls that file into its audit scope. This PR is the one that did. The entry records the consumers by path and the reason, matching the daemon route-handler entry directly above it, which exists for the same dynamic-`import()` limitation. * refactor: adopt the best of the parallel #1653 implementation Two sessions independently built #1638 (PR #1650 and PR #1653) and converged on the same architecture — trait in the registry, one engine with two entry points, runtime-command seam, lazy import, preserve-daemon, stored-baseline honesty. #1650 continues; this folds in what #1653 did better: - The agent-facing help core loop (cli-help.ts) now names scroll and back as settle-capable. Without this, the benchmarked closed-grammar help line kept instructing agents that --settle is only for press/click/fill/longpress — actively steering the AppControlBench models away from what #1638 shipped. - issueSettleRefs moves into session-snapshot.ts, beside the partial-frame primitive it wraps, deleting the single-function settle-ref-issuance module. - Their seam tests: back reader→writer settle plumbing, back CLI settle rendering, and a trait-less generic command (home) ignoring a stray settle flag rather than observing or rejecting. What #1650 had that #1653 lacked, for the record: the SETTLE_REF_ISSUING_TOOLS registry derivation (without it, MCP never pins a scroll/back settle diff's refs and the partial frame rejects every follow-up), BackCommandResult.settle in contracts, back's MCP output schema, paste-ready pinned diff refs, and the docs/changelog/baseline surfaces. * bench: help-conformance case for settled scroll-to-find planning The #1638 extension of the closed --settle grammar to scroll/back is the feature's entire payoff — collapsing scroll-then-observe into one call — and the closed command list is an enumerated N whose enumerator is this bench. The regex over the help text proves the sentence exists; this case checks whether a model plans differently because of it. One focused case, deliberately not coached: a pinned visible-first snapshot (rendered by formatSnapshotText, pinned by the sample-producers gate) whose wanted row is summarized off-screen with no ref anywhere in the output. The tempting pre-#1638 plan is `scroll` plus a separate `snapshot -i`; acceptance is the single settled call. Scoring was verified against eight plan shapes in both directions before recording. Model-backed record (claude-haiku-4-5, 3 trials, current help): 0/3 — but the decomposition is the finding. Settle eligibility GENERALIZED (3/3 trials put --settle on scroll unprompted; the mutation-suffix framing concern did not materialize) and the two-call habit is residual (1/3). All three trials failed on `scroll @e3 down --settle` — the pre-existing #1366 scroll-takes-no-target confusion, which the live CLI recovers with a dedicated hint but a single-shot bench cannot. The recorded gap is therefore a first-30 doc gap (nothing teaches that scroll takes no target), not a settle-eligibility gap; tuning the case until it passes would just delete the evidence.
40 KiB
Agent Device Domain Context
Durable vocabulary for this repo. Use these names in code, tests, issue titles, and architecture notes rather than coining parallel ones. You rarely need the whole file — jump to the section your task touches:
- Sessions, targets, devices
- Command surface & routing
- Interaction, refs, and guarantees
- Gestures & touch
- Snapshots & capture
- Recording & replay
- Maestro compatibility
- Providers, cloud, and the test harness
- Architecture — the two-registry end state
- Selector capture reliability contract — invariants any capture refactor must preserve
- Testing principles
Terms
Sessions, targets, devices
- Interactor: semantic interface between command dispatch and platform behavior.
- Platform module: platform-specific implementation behind the Interactor.
- Target: selected automation destination, such as mobile, tv, or desktop.
- Modality: broad supported device family, such as mobile, tv, or desktop.
- Session: daemon-owned state for a selected target and opened app or surface.
- Device lease: logical remote ownership of one selected device for a tenant/run/client and lease provider, separate from platform helper process locking.
- Device key: stable provider-scoped device identity used for lease contention, such as a simulator UDID, physical device id, or provider inventory id.
- Lease provider: remote connection source that routes and owns a device lease, such as
proxy, cloud bridge, orlimrun. - Runner/process lease: backend helper mutual-exclusion guard for platform runners or tools; it is not the remote client ownership boundary.
- iOS physical-device control: Apple-local module selected from discovery evidence. CoreDevice
devices retain the
devicectlcontroller; devices found only byxctraceuse the XCTest controller for readiness, app activation/termination, and cable-bound usbmux runner transport without claiming unsupported app inventory or installation capabilities. - Host process primitive: low-level host PID helpers in
src/utils/host-process.tsfor liveness, start-time/command reads, process listing, process-tree expansion, PID de-duplication, and best-effort signaling. It must not own domain cleanup policy such as browser ownership markers, runner lease reclamation, daemon takeover checks, or app-log PID metadata verification.
Command surface & routing
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
- Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution.
- Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior,
distinct from the public command surface and daemon command registry. The Swift runner traits
classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the
alert command as read-only only for its
getaction. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type. - Daemon RPC protocol version: integer advertised by daemon/proxy
/healthand checked by remote clients before HTTP JSON-RPC; bump only for breaking transport/request/response compatibility across the remote daemon boundary. - Version-skew invariant: a client replaces any local daemon whose version or code signature
differs (
isReusableDaemonInfo, both directions), so local client↔daemon skew cannot exist. Version-skew compat code is legitimate on exactly four axes and its comment must name one: the remote daemon boundary (the protocol version gates breaking changes only — older remote daemons silently ignore additive optional fields), separately versioned runner/helper binaries, persisted artifacts (.ad/config/env/session logs — handle or refuse with migration guidance, forever), and released API consumers. "Older local daemon" tolerance is dead code.
Interaction, refs, and guarantees
- Interaction dispatch path: one concrete route an interaction command takes to the device (runtime selector/ref resolution, direct iOS selector, native ref via web clickRef, coordinate, maestro non-hittable fallback). Every path classifies every guarantee in the ADR 0011 registry.
- Coordinate-first resolved element activation: iOS/macOS runner interaction pattern where a selector
or text query resolves the semantic
XCUIElement, then activation uses the element's resolved center coordinate when a frame is available. This keeps target selection semantic while avoidingXCUIElement.tap()post-action element re-resolution after normal navigation. tvOS remains focus/remote-driven. - Guarantee cell: one (dispatch path, guarantee) entry in
packages/contracts/src/interaction-guarantees.ts, classified as runtime/runner/delegated/inapplicable/waived. Completeness is a compile error; honesty is gate-tested. - Owned waiver: a
gap:-prefixed waived cell carrying atrackingIssueURL. Waivers are diffable debt with an owner, never folklore. - Delegation-on-error: a fast path falling back to the runtime path on semantic failure shapes. It closes failure-side guarantee cells only — never success-path parity.
- Parity table: golden JSON fixture under
contracts/fixtures/consumed by both vitest and the runner's gated Swift tests, so a cross-language rule (e.g. tap-point policy) cannot drift silently. Change the rule only via the table. - Coverage manifest:
CONTRACT_COVERAGEexport beside each interaction contract test file claiming which matrix cells it proves; the coverage gate requires every enforced/delegated cell to be claimed and rejects overclaims of waived cells. - Ref frame (ADR 0014): the session's single authorization namespace for mutation
@refs, kept separate from the latest operational observation (session.snapshot). It owns a frozen epoch (therefsGenerationthe client received), an immutable source tree, a lifecycle state (active/expired), and an issuance scope (allfor a complete snapshot, or the bounded set of ref bodies a partial publication emitted). Owned solely bysrc/daemon/ref-frame.ts. A complete snapshot activates anallframe;find/settled diff/replay divergence activate a bounded partial frame that supersedes the prior one; internal read captures never activate or reindex it. Replay captures return opaque, one-shot lineage evidence, and daemon response composition activates refs synchronously only after the exact inline or successfully written overflow projection is known. Every finalization attempt consumes its evidence. The outer replay retains its stable session lock plus the device lock when known through finalization, so external commands cannot interleave; nested replay actions reuse that scope and invalidate lineage through capture, ref-frame, side-effect, or session-lifetime changes. - Frame expiry seam (ADR 0014): every mutating leaf calls
expireRefFramesynchronously, immediately before the device op that may change element identity (after all pre-action guards), so a post-dispatch failure still leaves the frame expired — there is no success-only rollback. Ref resolution binds@eNagainst the frame's source tree, so an Android freshness (or any read-only) capture cannot retarget an admitted ref by positional coincidence; a fresh capture's coordinates are adopted only when its node's local identity matches. - Mutation admission (ADR 0014): a ref mutation is admitted only against an active frame whose epoch
and issuance scope authorize the ref (
admitRefMutation, order-sensitive reasonsref_frame_expired→ref_generation_mismatch→plain_ref_requires_complete_frame→ref_not_issued). Rejections carrydetails.reasonand name the lifetime failure. A ref-oriented sequence that performs several mutations must re-observe (snapshot), consume an honestly issued settled ref in pinned form, or use selectors. Read-only ref consumers stay fail-open with a staleness warning while the frame retains the ref's evidence. - Ref generation pin: optional
~s<n>suffix on an @ref carrying the snapshot generation it was minted from. Accepted as input everywhere, emitted by no tree output (snapshot token budget), auto-appended by the MCP layer, stripped and ignored by replay. - Deferred interaction outcome: the daemon's post-response answer to "did that mutation actually
take effect" — pending interaction outcome retry, post-gesture stabilization, and Android
snapshot freshness recovery.
src/daemon/deferred-interaction-outcome.tsis its one interface: every mutating route marks through it after dispatch, and every snapshot capture resolves through it; the threeSessionStatefields stay with their R7 owners (the module itself ownspostGestureStabilization, so the seam adds no node to the R9 cycle). Marking order is load-bearing (pending outcome retry before stabilization), each marker keeps its own eligibility gate, and the module owns only these post-action markers — never ADR 0014 ref-frame expiry or the ADR 0012/0016 staged protocols. Distinct from the same-response settled observation below. - Settled observation: opt-in (
--settle) post-action payload on press/click/fill/longpress and, on the generic route, scroll/back — the quiet-window stable loop re-captures until the UI settles, and the response carries the diff vs the pre-action tree (changed lines only, added lines with fresh refs,refsGenerationwhen the settled tree was stored). Best-effort: never fails the action;settled: falseplus a hint on never-quiet content. Which commands support it is a descriptor trait (postActionObservation), and the CLI flags, MCP fields, timeout envelope, and ref-pinning all derive from it. The two routes differ in ONE way, deliberately: the touch commands diff against the freshly resolved pre-action capture, while scroll/back — which resolve nothing — diff against the session's stored pre-action tree, so their diff reads "settled tree vs the last tree you observed". - Resolution disclosure (ADR 0012 decision 2): additive
resolutionfield on press/click/fill/longpress responses discloses how the acting path resolved its target —runtime/uniqueorruntime/disambiguated(withmatchCount/winnerDiagnostic/tiebreak/ up-to-5alternatives) on the daemon tree,ref/exactfor a resolved@ref(runtime-ref and native-ref),ref/label-fallbackwhen runtime-ref recovered a stale@refvia its recorded trailing label, ordirect-ios/not-observedon the XCTest fast path; absent entirely on the coordinate path and on dispatches whose runner actually executed the maestro non-hittable coordinate fallback (permission alone keeps the direct path'snot-observed). Pre-action diagnostics only:winnerDiagnostic/alternativesentries carry an opaque, non-@diagnosticRefthat is never ref-issued, never MCP-pinned, and cannot be reused as an@reftarget — a fresh snapshot/find is required before acting on an alternative.
Gestures & touch
- Gesture plan: typed, platform-neutral normalization of one- or two-contact gesture intent into bounded pointer trajectories. Contact topology is separate from motion; two-contact intent remains pan/pinch/rotate/transform even when native injection shares one executor. See ADR 0013.
- Android planned-touch executor: Android-local adapter seam that accepts
AndroidTouchPlan— the platform-neutralGesturePlanplus Android's stationary long-press plan — and selects the paired provider-native touch/viewport adapter or bundled instrumentation-helper adapter. Scroll and long-press retain their command semantics and only share physical touch execution through this seam. Helper long-press executes its absolute stationary path without a viewport probe; provider long-press receives its paired provider-owned viewport. See ADR 0013. - Multi-touch geometry: the internal initial span and angle plus centroid translation, scale, and rotation used to build both contact trajectories. Geometry is viewport-aware and fails early when the requested motion cannot fit; it is not a public tuning surface.
Snapshots & capture
- Snapshot capture plan: per-strategy ordered chain of iOS snapshot capture backends (recursive tree, query sweep, private AX) run by one plan runner under a shared wall-clock budget; recovery ordering is declared data, never a per-call-site branch.
- Snapshot quality verdict: structured outcome (state, backend, reason code, effective depth, collapsed leaves) computed once by the plan runner and shipped with every planned snapshot payload; the daemon and CLI render it instead of re-deriving degradation from node shapes.
- iOS WebView semantic presentation: the interactive snapshot projection that recognizes XCTest's
typed
WebViewroot and WebKit'sOther -> StaticTextwrapper pairs. It keeps raw diagnostics unchanged, presents ordinary wrapper text asStaticText, and presents wrappers carrying WebKit's numeric HTML heading level asHeading. - AX-unavailable target invalidation: iOS/macOS runner behavior where a root accessibility snapshot
failure such as
kAXErrorIllegalArgumentmarks the cachedXCUIApplicationtarget handle suspect. The runner fails closed for degraded interactive snapshots, clears the cached target, and lets the next command reacquire the app through normal activation.
Recording & replay
- Script recording: opt-in session mode armed before actions so a persisted
.adcan carry portable action inputs and recording-time target identity evidence. It is distinct from screen/video recording. - Recorded input parameterization: explicit fill authoring contract that sends literal text only to
the live interaction while the recorder stores
${VAR}before any durable recording/event/publication boundary. The caller owns the uppercase variable name; no selector or field-name heuristic infers sensitivity. Replay resolves the placeholder immediately before dispatch and preserves the authored placeholder if that run is recorded again. - Open-to-destination script: self-contained
.adscript with exactly one initialopen, a destination guard after its last app-state mutation, noclose, and an app session left active for subsequent work. Avoid: replay (artifact noun), fragment (reserved for lifecycle-free composition), partial script. - Destination guard: portable selector-targeted
waitnear the end of an open-to-destination script that confirms a landmark on the ready destination screen before replay hands the live session to its caller. - Recording backend: daemon-internal module interface selected per recording target that owns platform recording validation, output path policy, start/stop execution, and record-only cleanup below the daemon recording lifecycle.
Maestro compatibility
- Maestro program: source-preserving typed representation of supported Maestro YAML. It is interpreted directly through the compatibility runtime port and never lowered through generic replay action strings. See ADR 0015.
- Maestro observation generation: explicit compatibility-engine state identifying evidence captured since the most recent mutation. Queries may share semantic evidence within one generation; every mutation attempt invalidates it before dispatch. Interaction geometry is action-local: unique exact iOS selectors resolve and tap atomically in XCTest, while coordinate dispatch uses a fresh target snapshot. Rectangles are never shared across command boundaries.
Providers, cloud, and the test harness
- Provider: request-scoped adapter interface for external device, runner, or host tool execution.
- Provider-backed integration scenario: device-free integration test that runs the real daemon request path and replaces only external device or host tool execution.
- Cloud WebDriver runtime: package-shaped
ProviderDeviceRuntimeimplementation that maps a cloud-owned Appium/WebDriver session into agent-device lease, inventory, install, interactor, and release hooks without adding provider-specific branches to daemon routing. Cloud WebDriver adapters must expose explicit command capabilities because snapshots come from Appium page source rather than agent-device native iOS runner or Android helper backends. - CloudArtifact: provider-hosted session output such as video, Appium logs, device logs, automation
logs, or provider dashboard links. Cloud artifacts stay under the
cloudArtifactsresponse field so they do not collide with daemon-managed local/downloadableartifacts. - DaemonArtifactType: optional semantic category supplied by the command or adapter that owns a
daemon-managed downloadable artifact, such as
screenshot,screen-recording, ortrace-log. Finalization and inventory code must preserve this value when present, not infer it from filenames, fields, or MIME types. Missing artifact types must not prevent artifact registration. The type documents known values while allowing provider or command owners to introduce more specific strings. - Provider transcript: exact record of provider calls used when a test must verify platform command translation.
- Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands.
- In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener.
- HTTP contract test: narrow test that verifies JSON-RPC transport, auth, and response finalization over the daemon HTTP boundary.
Architecture (perfect-shape refactor, completed 2026-07)
ADR 0011 (interaction guarantee contract) is the interaction-semantics counterpart of ADR 0008's
registry thesis: the dispatch-path × guarantee matrix is declared once in
packages/contracts/src/interaction-guarantees.ts, completeness is type-enforced, honesty and coverage are
gate-enforced, and cross-language rules are pinned by golden parity tables. New dispatch paths and
guarantees are whole-matrix decisions, not local edits.
The perfect-shape refactor is complete and merged. Its end-state:
- Two derivation registries. One
CommandDescriptorper command (src/core/command-descriptor/registry.ts) is the single declaration site from which the public/internal/local command catalog, capability matrix, daemon command registry, batch allowlist, timeout policy, MCP exposure list, capability-checked CLI command list, post-action observation traits, and platform dispatch command set are derived by parity-tested projection. Command families still own surface metadata/CLI schema insrc/commands/**, but descriptor/catalog coherence guards prevent surface names from drifting; system command facets now project their simple Node client command methods. Closed public Node-client result contracts are narrowed throughCommandResultMap; action/backend-dependent methods remain explicitly broad until their public response projections are reconciled. See Node client result types. OnePlatformPluginper platform family (src/core/platform-plugin/) stops core/daemon from branching on platform, with the Apple plugin the first instance. See ADR 0008. - Typed result spine. Per-command typed results replaced the ad-hoc
Record-typed returns across the daemon/dispatch path; errors gained machine-readableretriable/supportedOnsignals onDaemonError(#939). Error-system conventions live in ADR 0010. - Apple platform model. Internally
Platformisapple(plusandroid/vega/linux/web) with anappleOsdiscriminant (ios | ipados | tvos | watchos | visionos | macos); the shared Apple engine lives undersrc/platforms/apple/core/with per-OS leaves undersrc/platforms/apple/os/<os>/. The public wire stays non-breaking:PUBLIC_PLATFORMS(packages/kernel/src/device.ts) still emitsios/macosleaf output. See ADR 0009. - Vega platform model. Initial Vega OS support is deliberately VVD-only: discovery returns
VirtualDevice, and platform capability admission rejects physical Fire TV devices until durable hardware evidence validates discovery, lifecycle, and the complete remote-control contract. Vega capture, selector, inventory, install, logging, and performance backends remain separate follow-up surfaces. - Folder DAG + layering lint.
scripts/layering/check.tsenforces five rules across four scopes in CI. GLOBALLY, across every production source file, it enforces the R1-R3 move rules (kernel-sink, commands-floor, platforms-seam) and rejects all production static value-import cycles. R1-R3 are declared as data inscripts/layering/zone-policy.ts(ZONE_POLICIES): which zones a boundary governs, which import kinds it tolerates, and which path prefixes are its declared seam. A fourth zone boundary is a table entry, not a fourth predicate.zone-policy.test.tsasserts each boundary fires and each exemption holds — necessary because the tree is clean, so a rule that stopped matching would look exactly like a rule being obeyed. Separately, it ranks an explicit target spine — as rank groups, lowest (kernel sink) to highest, whereA ◄ Bmeans B may not be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists:{ contracts, request, selectors, platforms, utils, replay, recording, snapshot, screenshot-diff } ◄ core ◄ { commands, cli-schema, mcp } ◄ { client, daemon-server, compat, remote, metro, sdk } ◄ daemon-client ◄ cli(the former rank-0 kernel zone lives inpackages/kernelsince #1490 W0, and shared selectors now live behind the private@agent-device/selectorspackage between@agent-device/ad-scriptand@agent-device/ad-replay; the formercloud-webdriverleaf lives behind the single@agent-device/provider-webdriverfacade since W1b, Limrun lives behind the single@agent-device/provider-limrunfacade since W1d, and the dependency-free XML codec lives behind the single@agent-device/xmlfacade; R11 package-boundaries owns these physical seams) — and rejects every back-edge within it. Only(root)is unranked amongsrc/zones (UNRANKED_ZONESinscripts/layering/model.ts): it holds the entrypoints and the composition roots that wire the command surface into the daemon, and R2 forbidsdaemon/from importingcommands/, so those files sit outside the spine by construction. Extracted workspace packages are classified separately and enforced by R11. The satellite zones used to be unranked too, on the grounds that ranking them would invent an order the architecture had not committed to; onceutilsjoined the spine and(root)was emptied of shared contracts, every one of them turned out to have a consistent rank already.model.test.tsguards that no new zone escapes this classification silently. Thirdly, R6 ratchets the SAME inversion measured over TYPE-ONLY edges, which R5 ignores by design: a type-only import is free at runtime, but "zone A is declared in terms of zone B" is still a boundary claim, and ranking type edges surfaced 61 inversions the gate had never seen.TYPE_INVERSION_BASELINEincheck.tsholds the remaining pairs with their counts; the numbers may only shrink, and a new pair fails outright. Down to 7, and each is a deliberate position rather than a misplaced declaration: 4 areAgentDeviceClientused as an opaque handle (the facade is built fromcommands/'s own projection registry, so moving it down is a design call about where that registry belongs, not a file move), and 3 are the ADR 0003 daemon descriptor, whose route type iskeyof typeof DAEMON_ROUTE_HANDLERS— derived from what the server actually implements. Both are explained at the baseline. - SessionState ownership (R7).
SessionStore.get()returns the live record out of a private Map andset()re-puts the same reference, so anysession.<field> = …in the daemon is a durable write to store-owned state — persistence depends on aliasing, not on an API call. That is workable while each field has an owner that keeps its invariants, soSESSION_STATE_FIELD_OWNERS(scripts/layering/session-state.ts) records them and the gate stops the set from growing quietly: a new field must declare an owner, a foreign write fails with the owner to call instead, and an owner that stops writing must be removed. The classification is exhaustive — every field is either in that table or inSTORE_OWNED_SESSION_STATE_FIELDS, the positive claim "the store establishes this and nothing mutates it later". Without that parity a new field with no direct write would satisfy the gate by being invisible to it, and R7 would silently stop covering part of the type it covers. A direct write to a store-established field fails, naming both remedies. Detection follows session records through aliased bindings (nextSession,provisionalSession,completedSession), not just a local namedsession: matching the literal name is what let three genuine foreign writes sit unreported. Since there is no type information in the gate, the binding test is a name test paired with the declared-field filter — a provider or runner session only registers if it also writes a fieldSessionStateowns, and the remedy is then the same. ADR 0014's ref frame and the snapshot lineage are the worked examples: the frame's four fields moved together across two modules untilactivateRefFrametook the transition, andsnapshotScopeSource+snapshotGenerationwere assigned insnapshot-runtime.tsuntilsetSnapshotLineagetook theirs. - Type-cycle growth (R9). R4 keeps the VALUE import graph acyclic, so every remaining cycle is
created by type-only imports — free at runtime, invisible to R5/R6, and the largest single
obstacle to reading a subsystem in isolation: inside a strongly-connected component of 47 files,
no file has a self-contained slice.
TYPE_CYCLE_BASELINE, derived from the zone ceilings inscripts/layering/daemon-modularity.ts, ratchets it for growth only, deliberately unlike R6: reducing it is a real refactor rather than a file move, so a hard equality would turn every unrelated improvement into a baseline edit. A shrunk tree is reported in the success line instead of failing. Hubs by in-component dependents:core/dispatch.ts(8),command-catalog.ts(7),commands/interaction/runtime/resolution.ts(6),core/command-descriptor/registry.ts(6). The former type hubs (runtime-contract.ts,commands/runtime-types.ts,backend.ts,commands/runtime-common.ts) left the cycle when #1632 sankbackend.ts's two upward type imports — 27 files stranded out of the component at once. - Daemon modularity ratchets (R10). The same tooling-only declaration pins R7's writer-owned
field/owner-claim counts, R9's 47 members by zone (
commands14,daemon-server17,core10,platforms2, root 3,client1), and the external production importers ofdaemon/types.ts(down to 2: the client normalizers and remote artifacts). R7 counts and external importers may only shrink; no zone may grow inside R9, and replay/Maestro/replay-test engine files remain outside it. This per-zone ratchet is intentionally stricter than R9's ordinary total-growth rule: even moving cycle membership into a zone at its ceiling must be justified by lowering another ceiling or changing the baseline explicitly. The #1478 extraction arc (P0–P5) completed against these ratchets: engines live behind thepackages/{maestro,replay-test,ad-replay,selectors}façades, engines cannot import daemon/platform/provider implementations, and no logical module may deep-import another module'sinternal/tree. The P6 platform-modularity phases were measured and deferred at the #1478 checkpoint (2026-08-04): the Apple perf edge no longer participates in any cycle, so the remaining R9 work sits at the command-registration hubs, not the platform seam. These are ratchets, not permission to scaffold façades before a real seam has two adapters. - Zero-dep CI jobs (R8). Some jobs run scripts straight from a checkout with
install-deps: false, so they have nonode_modules. Nothing local can feel that constraint — every dev machine hasnode_modulessitting right there — so a script grows a package import, passes locally, and fails the runner on the resolve. R8 reads the zero-dep job list out of.github/workflows/(declaring a job zero-dep is what puts it under the rule; there is no second list to update), walks each job's entry scripts and their whole relative-import closure, and requires every specifier to be a Node builtin or another repo file. A zero-dep job whose entry scripts the scan cannot identify fails too, so the rule cannot be escaped by changing how the job invokes them. Specifiers come fromoxc-parser's module record rather than a line scan, because these closures include--testfiles whose fixtures legitimately contain import syntax inside strings. - Agent-cost. Responses carry a cost block and MCP
outputSchema, rendered through a leveledResponseView.
Principles and their gates
The architecture rules this repo runs on are Clean-Architecture-shaped. Some are fully gate-enforced, some carry an open ledger that only shrinks, and some are norms with local evidence but no gate yet — each bullet below says which. When judging a design change, argue from the rule; when landing it, satisfy the gate where one exists.
- Dependency Rule (source dependencies point toward policy; details depend on abstractions).
Gate: layering R1–R3 import direction plus the ranked spine's no-back-edges check
(
scripts/layering/check.ts). #1405's "shared contracts below their consumers" is dependency inversion stated as a merge gate. Ledger:TYPE_INVERSION_BASELINE— type-only inversions where types still flow the wrong way; the ratchet only tightens, and the long-term target is zero. - Acyclic components. Gate: R4 bans value-import cycles globally. Type-only and dynamic-import cycles are deliberately tolerated by the gate; a report surfacing them as data is proposed in #1410 (the analysis half of the graph tooling, kept after the #1409 viewer was rejected) and is not landed yet.
- Policy × detail boundaries on demonstrated axes of change. The two registries are the two
demonstrated axes:
CommandDescriptor(what the system does) ×PlatformPlugin(how a device does it), ADR 0008/0009. Boundary-crossing enforcement is narrower than the principle: the apple-platform leak guard (publicPlatformString, provider-integration suite) gates one specific DTO class — internalapplenever reaching serialized public output — and the injectable Apple runner transport (runnerProvider, #1389) is one adopted seam, not a rule covering every boundary. Wider DTO/seam coverage is direction, not current enforcement. - Information hiding. Gate: R7 — every
SessionStatefield is classified and every write must occur inside its declared owner. Encapsulation of the one shared mutable object, enforced per-field. This coversSessionState; other shared state has no equivalent gate today. - Boundaries are earned, not speculative. Norm with local evidence, not a gate: the platform descriptor layer (~600 LOC of boundary nobody needed) was deleted, and the depgraph viewer (#1409) was closed unmerged. A new abstraction layer needs a demonstrated second consumer or axis of change. The one gated slice of this norm is tests: CI forbids test-only DI seams — a missing seam gets added as a real one or not at all.
- Module seams (the #1478 extraction's durable rules). State crosses seams as immutable
values, authority crosses seams as capabilities, and both only narrow; a capability seam earns
its port only when it has two real adapters, normally the daemon adapter and a deterministic
adapter running the same contract suite. A pure shared kernel instead stays behind its direct
package façade; the selector engine is the worked example. Calls go down through module façades
and back through ports; no inter-module event bus — ADR 0018's journal is an observation channel,
never a coordination mechanism. No engine receives
DaemonRequest,DaemonError,SessionStore, mutableSessionState, provider handles, or concrete platform implementations; the daemon adapter closes each capability over one already-admitted request, so an engine cannot express a session name, acquire a lock, or select a provider scope. Session state keeps three consistency disciplines distinct and never forces them through one generic transaction: immediate pessimistic transitions for ref/observation lineage (ADR 0014's mid-request expiry is why end-of-request commit/rollback is rejected), staged arm/complete/close-succeeded/commit-or-abort protocols with operation-keyed receipts for repair/publication (ADR 0012/0016), and append-only facts for recorded actions and diagnostics. Gates: R10 zero-count module policies, R11 package boundaries, the façade symbol pins, and R7 ownership. - Tests couple to stable interfaces. Norm (see Testing Principles) backed by the test-only-DI-seam gate above; broader test-strength enforcement is planned, not present — tracked under #1412.
- Component metrics are observatory data, never gates. Instability/abstractness per zone is a proposal (#1423, building on #1410's graph model) to locate concrete, high-fan-in modules worth pinning harder — explicitly never a CI threshold.
Deferred
The refactor is substantively done; these follow-ups are intentionally deferred, not lost:
- Dynamic Node-client results — interactions, observability, alert, React Native overlay, and settings remain broad until their action/backend-specific payloads have accurate public projections. See Node client result types.
- Legacy alias drops — ~175 LOC of legacy aliases/barrels remain, gated to the next major.
Selector Capture Reliability Contract
Selector capture is allowed to optimize transport, helper reuse, and polling, but it must preserve the observable freshness and failure semantics below before any runtime refactor.
- Direct iOS selector queries are a narrow fast path only: iOS, simple one-term
id/label/text/valueselectors, and never whilepostGestureStabilizationis pending. A direct miss may fall back to the snapshot selector path, but ambiguous matches and runner errors must surface instead of silently falling back.get textuses direct native selectors only for simpleidselectors because label/text/value reads need snapshot disambiguation. - Regular selector reads remain capture-backed.
@refs resolve against the authorized ref frame's source tree (ADR 0014), not whatever now sits at that index in a newer observation; selectorget/is/find/waitcapture through the backend.findandwaitpolling must bypass the 750 ms snapshot cache. The cache is also bypassed while Android freshness recovery or post-gesture stabilization is active. - Sparse snapshot quality verdicts are observable failures. Sparse captures must not replace
session.snapshot, and selector routes should report the sparse verdict instead of treating a root-only or sparse tree as an empty UI. - iOS sparse and AX failures are not proof of empty UI. Regular visible snapshots can recover through
the capture plan; raw and strict paths preserve failure.
runnerFatalinvalidates the cached target and must never refresh healthy mutation recency. - An
XCTEST_RECORDED_FAILUREafter an iOS tap is an ambiguous outcome, not proof that the tap missed. The daemon may take one same-presentation post-action capture against a usable retained snapshot; only a changed accessibility digest converts the result to success with a warning. Capture failure, sparse or mismatched presentation, and an unchanged digest remain failures so corroboration cannot turn an unknown tap into a false success. - Android helper reuse must not become snapshot result caching. Freshness is short lived, marked only after navigation-sensitive actions, compared against broad route-safe baselines, and not learned from scoped, depth-limited, interactive, or ref-refresh snapshots.
- Pending interaction outcome retry runs before post-gesture stabilization. Android freshness then composes when needed. Stabilization applies after swipe, scroll, gesture, or an explicit flag, and disables direct iOS selector shortcuts while pending.
setSessionSnapshotis the centralized session snapshot mutation path. Sparse captures do not write back, and empty@ref-scoped snapshot output must not replace the stored session snapshot.- Maestro target matching remains snapshot-based and policy-owned. Coordinate dispatch always uses a fresh target snapshot. A unique exact iOS match may instead reuse bound same-generation semantic evidence and dispatch through XCTest's atomic selector tap; structured live-selector failures return to fresh Maestro resolution. This optimization must not erase Maestro regex/string selector behavior, visibility filtering, provider-order first-match selection, explicit index selection, or assertion/wait semantics. Provider normalization belongs below the compatibility layer. Plain text is exact and regex-aware; do not add substring/fuzzy recovery, synthetic geometry, or hierarchy-shape heuristics that change authored selector meaning.
Evidence: ADR 0002,
ADR 0004,
ADR 0005,
ADR 0015,
find.test.ts,
snapshot-handler.test.ts,
snapshot-scoped-refs.test.ts,
runtime-targets-typed.test.ts, and
android-test-suite.test.ts.
Testing Principles
- Provider-backed integration scenarios should exercise the public daemon path whenever practical.
- Prefer the in-process provider scenario harness for broad scenarios; keep HTTP contract tests narrow and transport-specific.
- Provider seams sit below platform modules so integration tests still cover platform command translation.
- Provider transcripts are for exact external command contracts.
- Scenario transcripts are for broad, user-rooted workflows that should replace mocked handler unit tests.
- Unit tests stay for pure logic, parser matrices, selector matching, capabilities, and important edge cases.
Gate selection, speed rules, and shared fixtures live in docs/agents/testing.md.