* refactor(boundaries): move shared contracts below their consumers Acts on the depgraph findings: type-only edges are invisible to R5, so vocabulary that everything depends on had drifted above the zones that use it. - contracts/: the four platform-plugin facet tags (LogBackend, RecordingBackendTag, PerfMetricsSamplerTag, PlatformGatedProviderResolverKey) now live beside the plugin contract itself, which also moves out of core/; NetworkEntry moves next to the command surface that renders it; and the click-button, recording-export-quality, interactor-types and runner-lease-context vocabularies move down out of core/. - (root) drops from 29 files to 13: the internal *-contract/output/annotation modules move into contracts/, kernel/ (daemon-error, observability-redaction beside kernel/redaction), core/ (batch-policy, an ADR 0008 projection), commands/ (cli-command-aliases) and remote/ (upload-progress, upload-stream). What remains is entrypoints and the composition roots that R2 requires to sit outside the spine. - utils/ joins the ranked spine at rank 1 after its only two upward files move to the zones they were reaching for (cli/resolve-cli-options, cli-schema/cli-config), putting ~336 value edges under the gate. - Internal imports that routed types through the client-types re-export hub now name their real source. Type-only spine inversions drop from 61 to 35; the remainder is two clusters (client/client-types.ts and the ADR 0003 daemon facet). No behaviour change: 4470 unit tests and the layering gate pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * style: merge the duplicate contract imports the tag moves created Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * refactor(imports): name the declaring module, share find's argument rules Two follow-ups from re-measuring the graph after the boundary moves. 1. 89 type imports across 79 files routed through a re-export hub in another zone: `CliFlags` reached through commands/cli-grammar/flag-types.ts (52) when it is declared in contracts/cli-flags.ts, the replay suite result types reached through daemon/types.ts when they are declared in contracts/replay.ts, the doctor types through a daemon handler module, and so on. Each hop invented a cross-zone edge the architecture never asked for — including every apparent replay -> daemon and utils -> commands dependency. They now name the module that declares them. Within-zone hops are left alone; those are a local style choice, not a boundary claim. 2. `find`'s three positional/flag checks existed in both daemon entry points with hand-repeated messages, and the copy in dispatchFindReadOnlyViaRuntime was unreachable — its only caller validates first. Both now call checkFindArgs in selectors/find.ts, beside parseFindArgs and isReadOnlyFindAction, for the reason that module's own comment already gives: so the two paths cannot disagree. The refusal is returned rather than thrown, because the two mechanisms are not observationally identical in the session event log. Type-only spine inversions: 61 -> 35. 4470 unit tests and every gate pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * feat(layering): ratchet type-only spine inversions (R6) R5 ignores type-only edges by design — they cost nothing at runtime and do not affect cold start — so nothing was watching the direction they point. Ranking them the same way found 61 inversions, including contracts/ and utils/ declared in terms of rank-4 zones. 26 are fixed by the preceding commits; R6 pins the rest per zone pair so they can only shrink, and a new pair fails outright rather than being added to the baseline. The two remaining clusters each need their own change, and the baseline says so: the per-command Options/Result vocabulary declared inside the public Node-client surface, and the ADR 0003 daemon facet shape that core's descriptor registry composes. Both ratchet directions are covered: growth fails, and shrinking without lowering the number fails too, so the baseline cannot quietly stop describing the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs: record the import-graph findings behind this refactor A dated snapshot, not a normative document: when it disagrees with scripts/layering/, the gate wins. The graph tool that produced it lives on the claude/depgraph-viewer branch, deliberately out of this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * refactor(selectors): state the shared selector argument rules once R2 (commands-floor) forbids the daemon from importing commands/, and that is the right call: commands/ is the client-side surface — its only consumers are cli/, cli-schema/, mcp/, client/ and the composition roots — while the daemon is the executor on the other side of the wire. ADR 0008 protects exactly that seam. Relaxing R2 would let the executor depend on a client projection and pull CLI grammar and output formatting into the daemon's bundle. But the rule does force duplication: the daemon must validate independently because it accepts requests from any client, so 10 refusal messages existed in both zones. The only place a shared rule can live is below both, and selectors/ already held the parsers (splitIsSelectorArgs, splitSelectorFromArgs, isSupportedPredicate) and even the `is` predicate message — just not the checks that use them. Three drifts had already appeared in the `is` predicate rule alone: - commands/interaction/selectors.ts re-implemented the predicate list as an inlined seven-way `!==` chain while importing the message and hint from selectors/predicates.ts, so adding a predicate to the shared list would not have reached the CLI grammar. - That inlined chain compared the raw token, so the CLI rejected `is TEXT ...` while the daemon it hands the command to accepts it. The CLI now matches the executor; this is an intentional alignment, not an accident. - isCommand raised the same refusal without IS_PREDICATE_USAGE_HINT, so whether an agent got recovery guidance depended on which layer noticed first — the failure mode ADR 0010's audit calls out. checkIsPredicate, checkIsArgs, checkGetFormat, checkElementTargetArgs and checkWaitText now hold those rules, each beside the parser it wraps, and report a refusal rather than choosing how to raise it: the daemon returns a response, the command surface throws. Those mechanisms are not interchangeable — they write different session events — so the shared check stays out of that decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * feat(daemon): give ADR 0014's ref frame one transition, pin SessionState owners `SessionStore.get()` returns the live record out of a private Map and `set()` re-puts the same reference, so every `session.<field> = …` in the daemon is a durable write to store-owned state: 57 of them across 17 files, against 26 `set()` calls that are therefore ceremonial. Nothing at the store boundary can check what those writes are supposed to keep true. Measuring which module writes which field showed the problem is narrower than the raw count suggests — 16 of 27 fields already have exactly one writer. The sharp case is ADR 0014's ref frame: `refFrameState`, `refFrameScope`, `refFrameTree` and `refFrameGeneration` must move together or the frame is incoherent (an `active` state with a stale tree resolves refs against a namespace nobody authorized), yet complete issuance wrote them in ref-frame.ts and partial issuance wrote the same four in session-snapshot.ts. ref-frame.ts's own header claims to be "the single owner of the frame's transitions", and session-snapshot.ts documented itself as the exception. Both forms now go through `activateRefFrame`; they differ only in scope. `recordSession` deliberately moves alone in two paths (recording without arming a publication), so the save-script cluster gets no invented abstraction — it gets ownership instead. R7 records every field's owner and stops the set from growing quietly: a new SessionState field must declare one, a foreign write fails naming the owner to call, and an owner that stops writing must be removed so the table cannot drift into fiction. Field names are read out of the `SessionState` declaration, so a daemon module with an unrelated local named `session` — a provider or runner session — cannot trip it. 4475 unit tests and every gate pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs: record the reference semantics and refresh the findings SessionStore.get/set now document that the record is handed out live, since that is the fact behind R7. The findings snapshot picks up the resolved R2 question, the ref-frame consolidation and the two new gate scopes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * refactor(boundaries): rank every satellite zone, extract the provider port Second-order effect of the earlier rounds. With `utils` on the spine and `(root)` emptied of shared contracts, the eleven zones that were unranked "because ranking them would invent an order the architecture had not committed to" turned out to have a consistent rank already — the order was there, unasserted. Solving the constraint system showed one blocker: `utils/remote-config.ts` projected a remote-config profile into `CliFlags` while reaching up into `remote/`, and its only three consumers were in `cli/`. It moves there as `cli/remote-config-flags.ts`, and every satellite zone joins the spine. Ranked coverage goes from 730/895 files to 882/895. Only `(root)` stays out, and now for one stated reason: R2 forbids `daemon/` from importing `commands/`, so the files that wire them compose the spine from above. Ranking them exposed 22 type-only inversions R6 had never been able to see, and they were concentrated rather than scattered: - The device-provider port. `providers/` and `cloud-webdriver/` implement what the daemon calls, so both sides name `DeviceLease`, `LeaseLifecycleProvider`, `LeaseLifecycleContext` and `DeviceInventoryProvider` — now declared in contracts/device-provider.ts, below both. The adapters also imported the daemon's NARROWED `DaemonRequest` while only ever reading `req.flags`; they now name the public one from kernel/contracts. - `MetroPrepareKind` and the remote-config profile field groups move to contracts/ for the same reason: the command surface validates them and contracts/cli-flags.ts is composed from them. Two clusters remain, ratcheted with their reasons in TYPE_INVERSION_BASELINE: the client-types vocabulary, and `SessionAction`, which needs `CommandFlags` and `DaemonBatchStep` to move with it. Also fixes two things CI caught: the eight type re-exports my earlier import redirection orphaned (none published through any src/sdk/* entrypoint, so no public surface changes) and `isSupportedPredicate`, now module-private since `checkIsPredicate` is the admission API. `fallow-baselines/health.json` is keyed by path, so the moved cli-config entry moves with the file rather than being regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * fix(selectors): use the admitted predicate, not the raw option Review finding. `isCommand` called `checkIsPredicate` and then kept reading `options.predicate` for the capture policy, the `exists` branch, `evaluateIsPredicate`, the failure message and the returned result. Admission normalizes case, so an upper-case predicate was let past the gate and then evaluated against lower-case branches: `EXISTS` skipped its own branch and fell through to the generic path, and the result echoed the raw token. I widened admission at that surface without threading the normalized value through it — the CLI-grammar surface in the same change does use the admitted value. Every decision after admission now reads it. Two tests, both verified to fail without the fix: - a production-route regression driving `device.selectors.is` with `EXISTS`/`TEXT`, plus one pinning that an unknown predicate is still refused WITH the ADR 0010 usage hint; - a surface parity gate (selectors/__tests__/is-argument-surface-parity.test.ts) in the repo's existing parity style, asserting the daemon and CLI-grammar surfaces reach the same verdict and hand the same normalized predicate downstream across an input table. A helper-only test cannot catch a surface that admits correctly and then discards the result, which is what happened here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs: name the pre-push gate, and the formatter's path allowlist Both misses in this PR's review were process, not judgement, and the docs pointed the wrong way for both. AGENTS.md said "prefer the aggregate package.json scripts" without naming which aggregate, and CONTRIBUTING listed `pnpm test` and the targeted checks but never `pnpm check`. `check:tooling` looks like the gate and is a subset of it: it stops before the Fallow audit, so the dead exports this PR introduced passed a clean `check:tooling` and failed CI. Both files now name `pnpm check`, say what it covers, and say what it cannot (the device matrix). The same gap produced a second mistake twice: `oxfmt <path>` reformats whatever you point it at, while the repo's `format` script is an allowlist that excludes `scripts/` and every `.md`. One run reformatted 50 unrelated script files into a commit; the next nearly did it to AGENTS.md. AGENTS.md now says to run `pnpm format`, never `oxfmt <path>`. It also records the rule that cost a CI cycle: Fallow's baselines are keyed by path, so a renamed file needs its baseline entry moved, not the baselines regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * revert: undo stray formatter output across docs and scripts Three separate `oxfmt <path>` runs in this branch reformatted files the repo's `format` script deliberately excludes: 55 files under scripts/maestro-conformance plus scripts/perf, sync-mcp-metadata and the slow-test reporter, and 12 markdown files including six ADRs and docs/agents/. All of it was whitespace, quote style and markdown table padding — no content — but it inflated the diff a reviewer has to read and would have rewritten prose ownership across files this change has no business touching. All 70 are back to their origin/main content, so the diff outside src/ is now exactly this change's scope: three docs, scripts/layering, the Fallow baseline, and five provider integration tests. The rule this violated is now in AGENTS.md: run `pnpm format`, never `oxfmt <path>`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * style: reformat two provider tests with the repo's pinned oxfmt `pnpm format:check` failed in CI on the two files whose imports I merged by hand. The repo pins oxfmt 0.42.0 as a devDependency and both `format` scripts invoke `./node_modules/oxfmt/bin/oxfmt`; I had reformatted with `npx oxfmt`, which resolved 0.60.0, and the two versions disagree about wrapping a 100-column import. This is the rule AGENTS.md already states — run `pnpm format`, never oxfmt directly — so there is nothing to add to the docs, only to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * fix(ci): install deps for the layering guard, and gate the zero-dep contract The Layering Guard job failed with ERR_MODULE_NOT_FOUND on `oxc-parser`. The job ran with `install-deps: false` — no `pnpm install`, so no `node_modules` — and R7 had started parsing the daemon with oxc-parser instead of matching assignment operators with a regex. `pnpm check:layering` passed on every local run, because locally `node_modules` is always there. The job now installs dependencies. The alternative was to put R7 back on a regex, which cannot see `??=` or a computed `session[key] =` write, so it would trade a correct rule for a fast job. That leaves the interesting part: the zero-dep contract is real for the jobs that keep it, and it is invisible to every local run, which is the worst combination a constraint can have. R8 makes it checkable. It reads the zero-dep job list out of `.github/workflows/` rather than restating it — declaring a job zero-dep is what puts it under the rule — 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 from oxc-parser's module record, not a line scan. The closures include `--test` files, and a test about imports naturally embeds import syntax in a fixture string; the line scanner reported two such phantom violations in model.test.ts before the switch, which is how a gate stops being trusted. Verified by re-running the real gate against three injected regressions: the layering job back on `install-deps: false` (reproduces the exact CI failure, pointing at session-state.ts:24), a package import added to the still-zero-dep affected-selector closure, and a zero-dep job whose run step names no script. Also corrects the CONTEXT.md spine paragraph, which still described the satellite zones as deliberately unranked after they had all joined the ranked spine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * fix(layering): make R7 exhaustive, and follow session records through aliases Review finding: `SESSION_STATE_FIELD_OWNERS` covered 27 of `SessionState`'s 42 fields and nothing asserted parity, so a new field could be added and pass the gate by being invisible to it. R7's advertised claim — "every SessionState write is inside its declared owner" — was broader than what it checked. Investigating that turned up a second, larger gap the finding did not name: the scan only recognized a binding literally named `session`. The daemon names these records by role, so `nextSession`, `provisionalSession`, `completedSession`, `preRunSession` and `preEntrySession` were all invisible — and three of those writes were genuine violations R7 existed to catch: src/daemon/snapshot-runtime.ts:256 nextSession.snapshotScopeSource src/daemon/snapshot-runtime.ts:265 nextSession.snapshotGeneration src/daemon/handlers/session-replay-runtime.ts:707 preEntrySession.pendingRecordAndHeal The first two are the #1076 versioned-ref invariant: the generation advances exactly when the stored tree is replaced. That rule lived in `setSessionSnapshot` and had acquired a second statement of itself in snapshot-runtime.ts, whose own comment admitted the bypass. It now goes through `setSnapshotLineage` in the owning module. The third clears a watermark stamped by session-replay-resume.ts; `clearPendingRecordAndHealWatermark` puts the clear beside the stamp. Gate changes: - Binding detection accepts aliases, paired with the existing declared-field filter so an unrelated `…Session` local only registers if it also writes a field SessionState owns — where the remedy is the same anyway. - `fieldClassificationDrift` asserts parity in all three directions: unclassified, in-both, and naming a field SessionState no longer declares. - `STORE_OWNED_SESSION_STATE_FIELDS` classifies the 11 fields the store establishes at construction. It is a positive claim, so a direct write to one fails and names both remedies. - Four fields the widened scan made visible (`lease`, `deviceClaim`, `appName`, `saveScriptComplete`) got real owners. `nextSnapshotGeneration` is now module-private: replacing its only external call site orphaned the export, which `pnpm check` caught via Fallow. Verified against three injected regressions: a new SessionState field with no direct write (the reviewer's exact scenario), a foreign write through an alias binding, and a direct write to a store-established field. All three rejected. `pnpm check` green, 4486 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur * docs(daemon): correct the snapshot-lineage claim, and pin the real contract Device verification of the snapshot-lineage route found that a ref pinned before a `diff` keeps resolving with no pinned-ref warning. That is the designed ADR 0014 behaviour, not a regression — the comment describing it was wrong, and I propagated it. `main`'s comment in snapshot-runtime.ts said a diff "leaves client refs pinned to the previous generation, which is exactly what the pinned warning diagnoses". The counter and the authorization epoch are different clocks: - `diff` passes `issuesRefsToClient: false`, so it never reactivates the frame; - `resolveRefStalenessWarning` compares a pin against the frame EPOCH, not the observation counter, and its own comment says why — a capture that bumped the counter must not make a valid pin from the issuing frame look stale. So advancing the counter is not the same as invalidating client refs, and the observable the comment promised does not exist. I carried the sentence into `setSnapshotLineage`'s doc when the transition moved, and then into a hardware verification request, which cost a reviewer a device run against a false claim. `setSnapshotLineage` itself is unchanged and was a pure move: same expressions, same inputs as the inline assignments it replaced, so this route behaves exactly as it does on main. A comment that contradicts the code should be an assertion instead, so the contract is now pinned in session-snapshot.test.ts: the diff advances the counter, preserves the epoch, leaves the pre-diff pin resolving without a warning, and still warns for a pin from a different frame. Verified to fail when the epoch comparison is swapped for the counter. A second test covers the keep-current branch, which had no coverage. `pnpm check` green, 4488 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur --------- Co-authored-by: Claude <noreply@anthropic.com>
16 KiB
AGENTS.md
agent-device is a CLI + daemon that automates Apple-platform (iOS/tvOS/macOS), Android, and web
targets for coding agents. A long-lived daemon owns device sessions; commands route through a
registry-derived command surface to per-platform backends.
This file carries the traps and invariants you cannot infer by reading the code. Everything situational lives one hop away — load it when the task calls for it.
| When the task involves | Read |
|---|---|
| Domain vocabulary, architecture language, capture-reliability contract | CONTEXT.md |
| Accepted architecture decisions | docs/adr/README.md (a "read when you touch…" index) |
| Which gates to run, test speed rules, shared fixtures | docs/agents/testing.md |
| Adding or changing a CLI flag | docs/agents/cli-flags.md |
| Opening a PR, or reviewing one | docs/agents/pull-requests.md |
| Running commands against a real device | docs/agents/device-verification.md |
| Issues, PRDs, triage labels | docs/agents/issue-tracker.md, docs/agents/triage-labels.md |
| Web automation backend setup/diagnostics | docs/agents/web-backend.md |
| Planning device automation commands | agent-device help workflow, then topic help (debugging, react-native, react-devtools, physical-device, macos, dogfood) |
Versioned CLI help is the agent-facing source of truth for command behavior — prefer it over any prose in this repo, including this file.
Principles (expensive lessons — each cost an incident)
- Guarantees erode at path boundaries. Any new dispatch path or fast path classifies its cells in
src/contracts/interaction-guarantees.tsfirst; the typechecker forces completeness, you supply honesty. ADR 0011. - A registry claim is not a semantic check: never mark a cell
runnerwithout reading whether the Swift code implements the guarantee's definition, not just a similar-sounding behavior. - Delegation-on-error is not success-path parity. A fast path that falls back on failure can still succeed on a candidate the shared rules would refuse.
- Do not measure before confirming the code path can fire. An A/B whose B-arm cannot execute returns two green runs masquerading as evidence.
- Typed signals over message sniffing: key on structured details (
details.timeoutMs, reason codes), never on error text. Remaining sniffs are owned debt with in-code rationale — do not copy them. - Snapshot output is the token budget. Never add per-node bytes to the tree; response-level metadata rides once per response.
- Warnings compose, never clobber. Append through the shared response builder; two clobber bugs shipped before this rule.
- Unreleased API surface dies free. Before treating a field as wire-compat, check
git tag --contains <commit>; if it never shipped, delete it now. - Push only behind
&&-chained gates:format:check && typecheck && lint && vitest && git push. A push that can run after a failed gate eventually will.
Derived registries — read the declaration site, not prose
Command identity, routing, capability, and request-policy traits are derived artifacts. Inspect the declaration site rather than any map someone wrote down:
- one
CommandDescriptorper command:src/core/command-descriptor/registry.ts(catalog, capabilities, MCP/CLI projection, batch policy, timeout policy — ADR 0008) - daemon route ownership + request-policy traits:
src/daemon/daemon-command-registry.ts(parity-tested) - interaction dispatch paths × guarantees:
src/contracts/interaction-guarantees.ts(ADR 0011) - command names:
src/command-catalog.ts— never re-create command string sets in handlers - capabilities:
src/core/capabilities.tsis the only home for command/device support checks
src/daemon.ts stays a thin router and src/daemon/request-router.ts orchestration-only; command
logic belongs in handlers. New daemon handler-family commands update the daemon command registry.
Shared selector parsing/matching/resolution lives in src/selectors; request cancellation/progress
primitives in src/request; cross-layer platform and command data contracts in src/contracts. CLI
grammar owns flag declarations under src/commands/cli-grammar; cross-surface CLI schema composition
lives in src/cli-schema.
Enforcement gates (a failing gate located your incomplete change)
Invariants here are self-declaring gates. The correct response to a failure is to classify or cover the new thing — never to suppress or allowlist it.
- public CLI flags must be classified:
scripts/integration-progress-model.ts - guarantee matrix completeness + honesty:
src/contracts/__tests__/interaction-guarantees.test.ts(gap waivers need atrackingIssue; the pin list changes only in reviewed diffs) - every enforced/delegated matrix cell needs a contract scenario:
src/contracts/__tests__/interaction-contract-coverage.test.ts+test/integration/interaction-contract/ - interaction responses build only through
buildInteractionResponseData(construction-guard test) - every command declares a timeout policy on its descriptor (timeout-policy completeness test)
- TS/Swift rule parity: golden tables under
contracts/fixtures/, consumed by vitest and the gated XCTest — change the rule only via the table - cross-command apple-leak guard; folder DAG/import lint (zero value-import cycles, zero target-spine back-edges); fallow (dead code, duplication, complexity)
Hard Rules
- Process execution goes through
src/utils/exec.ts(runCmd,runCmdStreaming,runCmdSync,runCmdBackground,runCmdDetached). Do not import rawspawn/spawnSyncelsewhere — extend an exec helper instead. Plain.mjspackaging fixtures that cannot import TS helpers keep child-process usage local and preferexecFile/execFileSync. - Interactions use the daemon session flow:
openbefore,closeafter. keyboard dismissis the iOS keyboard dismissal path. It may tap safe native controls such asDone, but must not fall back to system back navigation.- Do not remove shared snapshot/session model behavior without full migration.
- Apple-family target changes keep
src/kernel/device.ts,src/core/capabilities.ts,src/core/dispatch-resolve.ts,src/platforms/apple/core/devices.ts, andsrc/platforms/apple/core/runner/runner-xctestrun.tsin sync. - iOS simulator-set scoping is iOS-specific:
iosSimulatorDeviceSetmust not hide the host macOS desktop target when--platform macosor--target desktopis requested. - Use
inferFillText(src/daemon/action-utils.ts),uniqueStrings(src/kernel/collections.ts), andevaluateIsPredicate(src/selectors/predicates.ts) rather than reimplementing them. - Do not update
skills/**/SKILL.mdfor command behavior or workflow guidance unless the user asks. Skills are thin routers to versioned CLI help; they must not carry behavior details.
Scope & shape
- Keep changes to one command family or module group unless the task explicitly crosses boundaries. If scope expands, stop and confirm. Preserve daemon session semantics and platform behavior.
- Do not inspect both iOS and Android paths unless the task is explicitly cross-platform.
- Prefer composition at platform boundaries: public aliases normalize into shared primitives, and providers contribute transport/device bindings instead of cloning interaction runtimes.
- Use
unknownonly at trust boundaries — parsed JSON, daemon/runtime payloads, catch values, generic I/O, parser callbacks. Once validated, narrow to a domain type instead of carryingunknownthrough internal helper and formatter signatures. - Before finalizing, do one tightening pass over touched and adjacent areas: drop obsolete code, redundant tests, stale helpers/fixtures, and duplication the change made unnecessary.
- Name durable module concepts with
CONTEXT.mdvocabulary. Do not coin parallel names across docs, tests, and code.
Module size is about agent context safety, and the unit is questions, not lines: a file should answer
one question so rg → read-whole-file stays one cheap bounded read.
- tripwires: target ≤300 LOC per implementation file; past 500, extract before adding behavior; past 1,000 is architecture debt unless it is generated data or a fixture snapshot. Tests are not exempt.
- name files by the domain concept they answer (
runner-cache.ts,interaction-touch-response.ts), not by layer leftovers (utils2.ts,common.tsaccretion). - colocate machine-readable claims with the code they describe — coverage manifests beside contract tests, registry cells beside enforcement pointers, decision comments at the decision site. Agents navigate by claims, not directory listings.
- test files mirror source topology 1:1; when a source module splits, split its test file in the same
PR. A 3,000-line family aggregation makes every fixture lookup a whole-file read.
interaction.test.tsand platformindex.test.tspredate this rule and shrink opportunistically — do not add to them. - shared fixtures are named exports in a sibling fixtures module (see
test/integration/interaction-contract/fixtures.ts), never inline literals repeated per test. - long guidance/data tables live behind focused modules, not beside parser/runtime logic.
- barrels only at package boundaries. Legacy internal barrels are gated for removal (
CONTEXT.md). - extract when it improves locality for a concept callers already need, not to hit a line count.
src/daemon/handlers/session.tsandsrc/platforms/apple/core/apps.tsare already over budget. Extract the Apple-family/macOS-specific helpers before adding behavior to either.
Toolchain gotchas
pnpmonly. Do not add or restorepackage-lock.json. ESLint/Prettier are gone — the lint/format stack is OXC (.oxlintrc.json,.oxfmtrc.json). Read.oxlintrc.jsonbefore treating lint output as a source-level bug.- Daemon state: packaged installs use
~/.agent-device; source checkouts use worktree-scoped dirs under~/.agent-device/dev/<basename-slug>-<hash>. Inspect withpnpm daemon:state-dir, override with--state-dir/AGENT_DEVICE_STATE_DIR, prune withpnpm clean:daemon --prune-dev. Daemons are isolated per worktree; devices are not — target different devices for concurrent worktrees. - Node ≥22. Prefer built-ins (
fetch, Web Streams,AbortSignal.timeout) over compatibility wrappers unless the surrounding code needs a lower-level transport. - Emit with
tsdown(Rolldown), typecheck with TypeScript 7 viatsc. Declaration generation uses the TS7 native executable and is stricter than a plain typecheck: if it fails, inspecttsconfig.lib.json(it needs an explicitrootDir: "./src") andtsdown.config.tsfirst, and runpnpm check:toolingfor any build-tooling edit. - Prefer the aggregate
package.jsonscripts; they encode the expected validation bundles better than ad hoc command lists — including which paths get formatted: runpnpm format, neveroxfmt <path>, or you will reformat files the repo deliberately leaves alone (scripts/, every.md). - Before pushing, the aggregate is
pnpm check(check:tooling && check:fallow && check:unit).pnpm check:toolingis a subset: it stops before the Fallow audit, so dead exports and complexity findings your diff introduces still fail CI after it passes clean. Fallow's baselines are keyed by path, so a change that RENAMES a file must move that file's entry infallow-baselines/health.json— regenerating the baselines would silently accept every other outstanding finding too.
Apple runner seams
The OS-agnostic XCTest runner lives under src/platforms/apple/core/runner/. Keep dependency
direction clean: transport below client/session behavior, shared command/error contracts in the
runner contract module, xctestrun preparation/build/cache isolated from request execution. For
connect errors, retry policy, or command typing, start in
src/platforms/apple/core/runner/runner-contract.ts before touching client/transport files.
Diagnostics, errors, logs
- Diagnostics source of truth:
src/utils/diagnostics.ts(withDiagnosticsScope,updateDiagnosticsScope,emitDiagnostic,withDiagnosticTimer,flushDiagnosticsToSessionFile). No ad-hoc stderr/file logging where these apply; redaction stays centralized here. - Request diagnostics belong in
sessions/<effective-session>/requests/<request-id>.ndjson. The top-level daemon log is for lifecycle/startup and pre-session failures. Session artifact paths come fromsrc/daemon/session-store.ts— do not hand-build them in handlers. - Logs backend:
src/daemon/app-log.ts.session.tsorchestrates only (start/stop/path/doctor/mark) and must not duplicate backend logic. App/device logs stay inapp.log; Apple runner andxcodebuildsubprocess output belongs in the session-scopedrunner.log. Preserve the external grep/tail workflow documented in help/skills. - Normalize user-facing failures via
normalizeError(src/kernel/errors.ts). Payload contract:code,message,hint,diagnosticId,logPath,details. Preservehint,diagnosticId, andlogPathwhen wrapping or rethrowing. Errors say what failed, why when known, and how to recover — recovery steps go inhintwhen the action is not obvious. --debugis canonical;--verboseis a backward-compatible alias.- An interaction that unexpectedly takes 5+ seconds is a daemon-log question, not an app question:
check the session
daemon.logor the failurelogPathfor runner restart, stale session recovery, AX failure, transport retry, or command timeout evidence. - Optional optimizations (cache/preflight/probe) are best-effort unless the feature contract says otherwise: on failure, timeout, non-OK, or unusable shape, fall back to the required command path. Keep their timeouts shorter than the operation they precede.
Selector system
- Interaction commands (
click,fill,get,is) andwaitaccept selectors and@ref. - Pipeline: parse → resolve → act → record selectorChain → re-resolve as a divergence suggestion on
replay failure. Call
buildSelectorChainForNodeafter resolving target nodes. - New element-targeting interactions must support selector +
@refand recordselectorChainsocollectReplaySelectorCandidates(src/daemon/handlers/session-replay-heal.ts) can rank it in a divergence report (session-replay-divergence.ts). ADR 0012 retired--update's silent rewrite-on-heal; there is no automated write path to hook into. - New selector keys stay centralized in
src/selectors/parse.ts; newispredicates belong inevaluateIsPredicate. - On macOS, snapshot rects are absolute in window space. Point-based runner interactions translate
through the interaction root frame — do not assume app-origin
(0,0). Prefer selector or@refover raw x/y in tests and docs, especially on macOS where window position varies across runs.
Known environment traps (do not debug these as regressions)
- The first
nodeexec right after the dev-signed Apple runner launches can block ~19s at 0% CPU (Gatekeeper re-verification). It poisons back-to-back CLI wall-clock timing; absorb it with a throwawaynode -e 0, or measure in-process/daemon-side. - A leftover session holding the device fails every subsequent command instantly with
DEVICE_IN_USEnaming the owner. The hint'sclose --sessionguidance is the fix, not daemon debugging. - Contention flakes:
request-handler-catalog("specialized daemon routes...") and the doctor provider scenario time out under host load. Before believing a regression, rerun in isolation AND reproduce on plainorigin/mainunder the same load. A changing failure set that passes in isolation is contention, not your change.
Docs & skills
- Before adding guidance, examples, schemas, or command metadata anywhere, decide which layer owns
it: the command surface, CLI grammar, CLI help, MCP projection, or daemon runtime. Picking the
layer after writing is how the same contract ends up duplicated across two of them.
docs/agents/cli-flags.mdwalks the layers for the flag case. - Decide docs impact with the change, not after. For behavior/CLI-surface changes: update
help/metadata, README or
website/docs/**when user-facing, and a SkillGym case intest/skillgym/suites/agent-device-smoke-suite.tswhen command-planning guidance changes. - Keep SkillGym cases behavioral and command-planning oriented: assert the user-visible contract and expected command family, forbid known bad patterns, avoid brittle exact output.
- State in the final summary whether docs/skills were updated, and why not if they weren't.
When guidance conflicts, Hard Rules win, then scope, then testing, then style.