* feat(screenshot): add crop-on geometry core and cropTarget selector rows
* feat(screenshot): declare crop-on flag, script round-trip, and snapshot runtime plan
* feat(screenshot): run the crop leaf after the platform write and before scale
* feat(screenshot): expose --crop-on in the CLI and surface crop warnings
* chore(gates): declare crop-on capture-kit subpaths and scope the crop scenario exemption
* refactor(screenshot): split crop target/policy module and trim redundant coverage
Address review comments at 570da2c417:
- Split the 328-line screenshot-crop.ts leaf: the target acceptance matrix,
classifier, and pre-device argument policy move to screenshot-crop-target.ts,
so both implementation modules meet the 300-line target.
- Reuse kernel isPositiveFiniteRect/rectArea in the rect-projection module
instead of redefining them locally.
- Drop the crop-on CLI forwarding case (redundant with screenshot-options
flag-mapping coverage + the generic dispatcher) and the transport-based
warnings case, replacing the latter with a focused screenshot-result unit
test. This also returns the two legacy aggregate test files to their
merge-base length for the test-file size ratchet.
* refactor(screenshot): extract macOS crop-target decision to keep classifier under the complexity budget
classifyAppleCropTarget inlined the macOS surface decision, pushing its
cyclomatic complexity to the fallow threshold. Move it back out to a
small helper so the target classifier stays within budget.
* refactor(screenshot): dedupe the meaningful-signal predicate and polish png-crop
- Hoist isMeaningfulSignal into @agent-device/contracts/snapshot (next to
normalizeType/isMeaningfulLabel) so the ref overlay and the crop
rect-projection share one copy instead of each carrying an identical
private predicate. Behavior is unchanged.
- png-crop: isCropBox was a no-op 'box is Rect' predicate (input already
Rect) — make it a plain boolean, and tighten the doc to the contract.
* refactor(screenshot): drop the dead crop outcome flag and cover the projection seams
- ScreenshotCropOutcome.cropped was a constant true that no caller read;
the crop either returns (success) or throws, so the outcome reduces to
the partialIntersection observation.
- resolveScreenshotRectSpace and resolveSnapshotBounds were the only
projection exports without coverage: pin the accepted-backend map, the
unaccepted-backend typed refusal, and the viewport-root / union / empty
bounds branches.
* fix: encode the mcp subcommand in server.json package arguments
A registry-format launcher (e.g. one consuming /.well-known/mcp.json or the MCP registry entry) starts the server from the package descriptor only; without the positional "mcp" argument it runs the bare CLI instead of the stdio MCP server (bin.ts only starts the MCP server for the mcp subcommand).
Enforce the argument in scripts/sync-mcp-metadata.mjs so sync and the CI/prepack checks (check:mcp-metadata) keep server.json correct, and regenerate server.json.
* test: own the registry launch-argument invariant; add changelog entry
- scripts/__tests__/mcp-metadata.test.ts asserts the checked-in server.json's agent-device npm package entry declares the exact fixed positional mcp argument (and stays stdio-only), so a missing or wrong argument fails the unit lane in both directions. Wired into the unit-core project include list.
- Changelog: user-visible release fix under Unreleased.
* test(ios): add guest simulator AX bridge evidence
* test(ios): make alert cleanup selector unique
* test(ios): admit recovered alert cleanup surface
* test(ios): narrow AX spike to guest evidence path
* docs(ios): record guest AX bridge decision
* chore(ios): remove unused spike import
* docs(ios): correct simulator bridge verdict
* test(ios): drive the guest Simulator AX bridge directly from Node
Replace the idb companion + Python reader in the #2192 spike with a Node client
for idb v1.5.2's in-Simulator SimulatorFrameworkBridge: one private guest per
session spawned through simctl, 4-byte length-prefixed JSON over a UNIX socket,
single-fetch traversal with automation mode asserted per request, nested trees
flattened to parent-linked raw nodes with XCTest type names, and typed
crash/timeout/cancel/stale-generation failures.
The targeted harness now observes app readiness with a throwaway probe instead
of admitting on pid presence, relaunches the app per bootstrap sample, records
host load per sample, and runs recovery probes through the adapter. Hard tiers
follow the corrected #2192 contract (warm 300/500 ms, relaunch 500 ms); the
former 75/150 ms and 250 ms values are reported as stretch findings. Preboot
preference edits are optional and unused by the guest path.
The prototype's targeted artifact is preserved under a -python-prototype name;
its bootstrap and recovery samples measured the packaging, not the mechanism.
* test(ios): narrow Simulator bridge decision evidence
* docs: publish Simulator bridge evidence out of tree
* fix: tighten iOS bridge evidence gates
* docs: publish corrected bridge evidence
* docs: point to post-rebase bridge evidence
* fix(remote): materialize test suite artifacts against a remote daemon (#2246)
`agent-device test` crashed with ENOENT against a remote daemon because the
scheduler resolved `--artifacts-dir` against the caller's `cwd`, sent over the
wire, on the daemon's own filesystem. Mirrors #1802's read-side fix for the
same command: the CLI now redirects `--artifacts-dir` to a temp directory the
daemon owns before the suite runs, and the daemon rewrites every artifact path
in its response back to the caller-local root and registers the suite
directory as one downloadable artifact through the existing screenshot/record
transport, extended here to also support directories via the codebase's
existing safe archive extractor (the archive comes from a remote daemon, a
different trust domain, so a raw `tar` invocation was not enough).
* fix(remote): publish test artifacts atomically
* perf(cli): keep artifact downloads lazy
* feat(daemon): add the allocator-held device claim kind
ADR 0021 foundations, unit 2. A device an allocator-managed pool owns is held for that identity's
whole pool lifetime, not for a session, so the claim store gains a second record kind whose
principal is an installation instead of a process. Nothing writes one in production yet.
- `AllocatorHeldDeviceClaim` is a SEPARATE record at `schemaVersion: 3` with `kind: 'allocator'`:
`stateDir` + `allocator.instanceId` + `allocator.identityIncarnationId`, and no
ownerPid/ownerStartTime/ownerToken/session/workspace/abandonedAtMs — a record carrying any of
them does not decode. `DeviceClaim` and its v2 files are untouched, so an older daemon never
meets a changed process-owned record and only ever sees a v3 file for a managed identity, which
it reads as an unreadable claim record and fails closed on.
- The managed owner is DERIVED from the recorded allocator instance (`allocatorHeldClaimOwner`),
never stored, so an owner that disagrees with the claim's principal cannot exist.
- `InspectedDeviceClaim` becomes a union: the allocator member carries `allocatorClaim` and
`claim: undefined`. Every clearing surface — ownership match, abandon, stale release, the
startup sweep, acquire-path reconciliation, session close, lease expiry, the shutdown ledger,
and `processOwnsActiveDeviceClaim` — reaches for `claim`, so none of them can be written against
an allocator-held claim, and `DeviceClaimReconciler` stays typed to the process-owned record.
- New classification `allocator-held`: not stale, not owner-releasable. `device status` shows it
in the normal view with its allocator, incarnation and installation; `device release --stale`
refuses it with `allocator-held-owner`.
- The verifier gains `covered` and `incarnation-stale`. A session or transient command whose fence
names the incarnation the claim holds executes under it, acquiring and clearing nothing; a fence
for a re-provisioned identity is refused, because an incarnation is stable for that identity's
pool lifetime.
- The ORDINARY arm of the admission gate now performs a read-only inspection under every policy
but `none` and refuses `DEVICE_IN_USE` / `DEVICE_CLAIM_ALLOCATOR_HELD`: `apps` and `app-state`
boot a device through `ensureReady` exactly as a mutation would. It still acquires nothing, so
the policy/claim-file table is unchanged. That reason is deliberately outside
`DeviceClaimConflictReason`, so replay never retries it as infrastructure. The inspection reads
the record and stops (`readAllocatorHeldClaimFile`): the owner-liveness probe `inspectDeviceClaimFile`
runs costs a host process observation per binding, and this kind has no owner process to observe.
- `acquireAllocatorHeldDeviceClaim` reattaches only on the full principal and never reconciles or
supersedes an ordinary claim (a conflicting ordinary claim prevents publication, ADR 0021 §4);
`releaseAllocatorHeldClaim` takes removal proof and is the only clearing path. Both are
by-design production-unused in this unit, with a comment naming the unit that will call them.
- The record/decoder and the lock/write leaves move out of device-claims.ts into
device-claim-record.ts and device-claim-store.ts, which dissolves the
device-claims <-> device-claim-inspection type cycle and lets the allocator module share the
writer without device-claims.ts ever importing it.
`devices` still reports no `claimedBy` for an allocator-held claim: the public field is
`{ session, workspace }` and this kind has neither.
* docs: condense CONTEXT.md entries to leave room for sibling units
Wording-only tightening of 41 existing definitions. No term is added, removed or redefined, and the
five entries unit 1 added and the three unit 2 touched are left alone. The glossary ends at 11,589
of the 12,000-byte guidance budget, which leaves room for the sibling unit's vocabulary to land
without a second trim.
* fix(daemon): register device-claim-store as the claim file's atomic-publish owner
device-claims.ts no longer writes its claim file directly: it delegates every write to
writeDeviceClaim in the new device-claim-store.ts, the single writer shared by the process-owned
and allocator-held claim kinds. atomic-publish-ownership.test.ts's SIMPLE_PUBLISHERS list still
named device-claims.ts, so its source-grep for `publishFileSync` no longer matched anything and
the ownership gate failed in CI. Register device-claim-store.ts as the owner instead.
Also rename DeviceClaimRecord -> StoredDeviceClaim (and decodeDeviceClaimRecord ->
decodeStoredDeviceClaim): the shutdown ledger's own DeviceClaimRecord in daemon-shutdown-report.ts
is a different type (one row of what teardown released), and the shared name invited confusing
that unrelated type for this module's claim-file record.
* fix(daemon): extend the allocator-held admission exhaustiveness table
The rebase onto adr0021/u1-owner-kind's decideAllocatorHeldAdmission fix carried forward
ADMITTED_BY_OUTCOME_STATUS and its outcomes array from before this branch added the covered and
incarnation-stale statuses, so the table no longer covered the whole AllocatorHeldClaimAdmission
union and TS2739 caught it. Add both: covered admits, incarnation-stale does not.
* fix(daemon): fail closed on a corrupted allocator-looking claim record
readAllocatorHeldClaimFile gated on entry.allocatorClaim, which is only set once a record
decodes all the way through. A v3-schema record corrupted into also carrying a process
principal field fails decodeAllocatorHeldClaim's carriesProcessPrincipal check and
decodeStoredDeviceClaim returns null -- exactly like a record that never existed. Ordinary
admission's inspectAllocatorHeldDeviceClaim then read that as "no allocator claim" and let
observe and every other non-transient-exclusive policy proceed, against a device an
allocator may actually hold.
A record that declares schemaVersion 3 but fails to decode is not provably a non-allocator
record, so it cannot be treated as absent. Add looksLikeAllocatorHeldClaim (device-claim-
record.ts) to distinguish "declares the allocator schema, doesn't decode" from every other
kind of corruption, give it its own classification (allocator-inconsistent), and have
readAllocatorHeldClaimFile return the entry -- not null -- for it. Ordinary admission then
refuses through the existing deviceClaimConflictError path (DEVICE_IN_USE /
DEVICE_CLAIM_OWNER_UNCERTAIN), same as any other claim it cannot verify.
deviceClaimRequiresStaleInspection, deviceClaimOwnerCannotRelease and conflictReason are
exhaustive switches over DeviceClaimClassification, so the new member forced a decision at
every site rather than one that could be missed: not stale (nothing dead to surface), not
owner-releasable (no process proof exists to make), and DEVICE_CLAIM_OWNER_UNCERTAIN like
the other undecodable-record classifications, not a permanent-condition reason.
Planted-red verified: reverting readAllocatorHeldClaimFile's gate to entry?.allocatorClaim
alone makes the new regression fail with error.code 'UNKNOWN' -- admission resolves with no
error at all, exactly the silent pass-through this fixes.
* refactor(daemon): fold staleReleaseRefusalReason into a lookup table
The switch exceeded fallow's health gate at 10 cyclomatic / 31.6 CRAP once the
allocator-inconsistent case joined it. A Record<DeviceClaimClassification, string> reads as
one branch to the complexity walker instead of one per case, while TypeScript still refuses
to compile a missing key -- the same exhaustiveness guarantee a switch gave, at a fraction
of the counted complexity.
* docs: trim CONTEXT.md back under the 12,000-byte guidance budget
Main gained bytes elsewhere since this stack's own docs-condense commit landed, pushing
CONTEXT.md to 12,028. Tighten four of this stack's own entries (managed local owner, request
generation, identity incarnation, managed device allocator port) rather than touch anyone
else's; 11,947 bytes leaves headroom against the next PR that lands first.
* docs: trim the CONTEXT.md glossary within the guidance byte budget
CONTEXT.md sat at 11,992 of its 12,000-byte guidance budget, so no new domain term could be added
without first paying for it.
- Condense eighteen definitions that had grown past one line (platform leaf, command surface,
runtime use, runner command traits, interactor, coordinate-first resolved element activation,
parent-owned touch point, guarantee cell, delegation-on-error, ref frame, snapshot producer,
snapshot policy facet, capture hint, regular presented-depth frontier, clip fold,
AX-unavailable target invalidation, Maestro program, Maestro observation generation). The
definitions keep their meaning; only the elaboration is gone.
- Move the five test-harness terms of 'Providers and tests' (provider-backed integration
scenario, provider transcript, scenario transcript, in-process provider scenario harness, HTTP
contract test) to docs/agents/domain.md, which AGENTS.md already routes to for domain
vocabulary. None of them names a concept a command or a wire shape carries, and none appears in
a test name.
CONTEXT.md is 10,517 bytes after this pass.
* feat(runtime): add the managed-local owner kind, device-claim rule, and managed binding fence
ADR 0021 foundations, unit 1. Nothing registers a managed local owner yet, so every arm below is
reached from tests only; the point of the unit is that the arms exist and fail closed.
- `RuntimeOwnerRef` gains `{ kind: 'managed-local'; instance }` with `managedLocalRuntimeOwner`:
one owner per allocator instance, family-agnostic because the device carries its family. Every
owner-kind discrimination becomes an exhaustive switch, so a fourth kind is a type error at each
site: the owner key, the unavailable-facts provider mode, the durable envelope decode, and the
gateway's provider-mode acceptance and exact-owner selection.
- `deviceClaimRuleForOwner` ('ordinary' | 'allocator-held' | 'none') in the new leaf
src/daemon/device-claim-rule.ts replaces the boolean `isLocalDeviceClaimTarget`. Both claim
gates switch on it, and the admission gate now evaluates it under every device-claim policy: the
`transient-exclusive` condition moved inside the ordinary arm, so a managed owner is verified
where an ordinary owner would never have touched the store.
- `requireAllocatorHeldDeviceClaim` (src/daemon/device-claim-allocator.ts) is the one read-only
verifier both gates consult. It never acquires, never locks and never clears; in this unit it
can only answer `binding-invalid`, `missing`, or `conflict`, because no allocator-held claim
kind exists until unit 2. `allocatorHeldAdmissionError` answers each outcome with its own
refusal through an exhaustive switch, so an outcome the verifier learns to produce is a
compile error until it is answered.
- A missing allocator-held claim refuses with COMMAND_FAILED / `allocator-claim-missing`,
`retriable: false`. It is deliberately not a `DeviceClaimConflictReason`: replay retries every
conflict reason as infrastructure, and a managed identity no allocator activated is permanent.
- `managedBindingFence` / `decodeManagedBindingFence` encode `[requesterId, identityIncarnationId]`
as the fence token and the request generation as its generation, so two requesters on one
identity incarnation never share a fence. The ids are fenced verbatim, and the decoder accepts a
token only if it re-encodes to itself.
- Claim admission now receives the binding intent the gateway bound, so an exact-owner fence
reaches the gate unchanged. Session open still binds ordinarily and passes an ordinary intent:
a managed local owner is therefore refused there structurally, and the Host open route replaces
that intent when it lands.
- CONTEXT.md: managed local owner, device-claim rule, managed binding fence, request generation,
identity incarnation.
* fix(daemon): decide allocator-held admission totally instead of by an optional error
`allocatorHeldAdmissionError` returned `AppError | undefined`, so its switch without a default
was never exhaustiveness-checked: TS2366 fires only when the return type excludes `undefined`,
`noImplicitReturns` is off, and oxlint has no exhaustiveness rule. A verifier outcome nobody
answered would therefore fall out as `undefined`, which both gates read as an admission — claim
admission throws nothing and session open proceeds to open the session on a device it never
verified.
Replace it with `decideAllocatorHeldAdmission`, returning
`{ admitted: true } | { admitted: false; error }`. The return type excludes `undefined`, so
dropping an arm is now a compile error at the switch, and a gate asks whether the outcome was
admitted rather than whether an error happened to come back. `buildAllocatorHeldRefusal` and the
admission gate are projections of that one decision.
* docs: restore the meaning five CONTEXT.md definitions lost in the trim
The condensing pass shortened these five past the point where they still said what they meant:
- Capture hint said 'presented depth' where the term is 'regular presented depth', which is what
Regular presented-depth frontier is measured against; the short form read as a different axis.
- Clip fold lost both that the interpreter runs inside presentation for every backend and that a
platform difference may not enter as a backend exception. Those are the whole rule.
- Snapshot policy facet lost the process boundary that makes it host-side at all: runner-side
Swift presentation stays separate.
- Runner command traits lost 'independently of the public command surface', which is what
distinguishes them from the command surface.
- Delegation-on-error said 'settles', and Settled observation makes 'settle' a term of its own.
CONTEXT.md is 11,674 of its 12,000-byte budget.
* docs(daemon): correct the claim-gate and managed-owner comments
- The claim-gate docstring claimed there is no other way to obtain device operations. That is
true of command handlers, but two daemon-owned recovery paths bind outside the seam:
application-lifecycle-recovery.ts (ordinary intent, daemon shutdown) and
durable-capture-runtime-recovery.ts (exact-owner intent read back from a durable envelope,
which this unit makes able to carry a managed local owner). Name them instead of claiming
coverage the seam does not have.
- The open path's comment described a session executing under an allocator-held claim, a state
this route cannot produce. Say what the `{ kind: 'ordinary' }` literal actually is: the truth
of a route that binds ordinarily, which the Host open route replaces with the request's exact
intent when it lands.
- Name U3 as the unit that fills the exact-owner selection arm, rather than the whole ADR.
* fix(runtime): accept transport-composed facts for a managed owner
providerModeMatchesOwner's managed-local arm accepted mode === 'local' only, but
selectExactOwner's managed-local arm loads the device's local family owner through the same
loadLocal a local-family owner uses, so it inherits that owner's provider modes verbatim. A
managed binding over a transport-composed local device (e.g. a remote ADB or web-provider
transport) would fail bindingContractFailure's facts check and be rejected as an owner/facts
mismatch. Accept the same local-family modes the local-family arm already does; still
unreachable until U3 registers the exact-only owner, which is where the binding regression
test that pins this lives.
* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port (#2259)
* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port
ADR 0021 foundations, unit 3. Unit 1 added the `managed-local` owner kind and left the gateway's
exact-owner arm for it failing closed; this unit gives that arm a registry and the owner it selects.
Nothing in production registers a managed owner yet, so both are reached from tests only.
- `createComposedPlatformRuntimeGateway` gains a `managedOwners` list that only the `managed-local`
arm of `selectExactOwner` reads. `selectOrdinaryProvider`, `inspectFacts` and the ordinary `bind`
arm never see it, and `providerModules` pairs one provider-runtime owner with one
`ProviderDeviceRuntime`, so ordinary selection cannot reach a managed owner by construction
rather than by a check. A duplicate instance is refused at composition.
- The wrapper (src/platform-runtime-managed-owner.ts, root zone, no platform imports) binds only
under an exact-owner intent naming itself, loads the device's own family owner through the
gateway's loader, delegates with an ordinary intent — a family owner refuses a foreign exact
owner — and republishes the binding under the managed owner. It does not read the fence: what a
managed binding fence proves is the device-claim gate's business. `ownsDevice` returns false.
- Twenty cells are withheld as `owner-capability-missing`, enumerated by mechanics rather than by
catalog group: the four device-lifecycle cells, the four application cells that boot or shut the
device down (`prepareApplicationOpen`, `prepareAppleRunner`, `closeApplication`,
`finalizeApplicationClose`), and the twelve durable-capture cells, which a managed binding could
never reattach because the family runtime stamps envelopes with its own local owner. The
operations are then filtered by those facts, so an operation cannot outlive its own fact.
- `@agent-device/contracts/managed-device-allocation` is agent-device's own allocator port: lease
request, lookup, supersession, cancellation, renewal, release, activation confirmation, identity
status, removal acknowledgement, and the typed environment projection. Types only, named to match
the allocator's published contract so the two sides cannot drift, with no dependency on any
allocator package. Its only implementation is a scripted fake under `*.fixtures.ts`.
- Budgets: the new contracts entry surface is a one-module closure; the `src/platform-runtime.ts`
hub moves 47 -> 48 for the wrapper, whose own value imports were already in that closure.
* fix(runtime): withhold the deployment cells from a managed binding and trim the allocator port
Review findings on the managed local owner.
- `deployApp` and `deployMaterializedApp` join the lifecycle group. Both family deployment runtimes
ensure device readiness before installing, and `deployAppUse` requires `deployApp` alone — so
`install` on a managed binding would have booted the allocator's device with nothing to refuse
it. Twenty withheld cells become twenty-two, and the refused-uses test covers `deployAppUse`.
- The wrapper's doc comment no longer implies that withholding cells is a complete lifecycle
exclusion: several retained Apple cells (screenshot capture, settings, clipboard, application
launch) boot the simulator lazily inside the family runtime, where cell selection cannot reach.
That is the same class as the pre-binding readiness path, and closing it is a family-runtime
change.
- `readLeaseEnvironment` leaves the allocator port. It was beyond the vocabulary the contract
fixes, and it made the scripted fake carry a real parser whose only test passed with every
production line reverted. `ManagedLeaseEnvironment`, `ManagedLeaseEnvironmentKey` and
`LeaseEnvironmentError` stay as types; the reader that produces them lands with the unit that
first turns a grant into a device.
- CONTEXT.md drops an operation enumeration that was already incomplete.
* fix(runtime): withhold the lazily-booting Apple system and screenshot cells
Screenshot capture, settings, clipboard and application launch were retained on a managed
binding even though their Apple family-runtime implementations can boot the simulator lazily
below cell-selection granularity (screenshot's shutdown-failure retry boot; settings, clipboard
and application launch each resolve a local interactor the same way). That preserves rather than
blocks the exact bypass ADR-0021 section 3's hard boundary names: managed lifecycle/readiness
belongs to the allocator, and no handler path may fall back to direct lifecycle tooling.
Withhold captureScreenshot, setSetting, readClipboard, writeClipboard and openApplication
alongside the existing withheld groups. The wrapper's doc comment now names the pre-binding
readiness gap explicitly as the same class of follow-up, rather than folding it into a retained-
cells caveat that no longer applies. MANAGED_RETAINED_OPERATION moves to tapPoint, the cell the
fixture-based regression tests now use to prove something survives the wrapper.
* chore: retrigger CI (stale synchronize event after rebase)
* fix(runtime): lazy-load the managed owner wrapper to satisfy the eager-closure no-growth gate
Main's eager-closure budget gate (the merge-base ratchet) replaced the hand-tracked
HUB_BUDGETS map with an automatic no-growth-vs-merge-base check: src/platform-runtime.ts
is a hub with no growth allowed at all, not a number bumped by hand with a justifying
comment. The static import of createManagedLocalRuntimeOwner in platform-runtime-gateway.ts
added one module to that hub's closure (47 -> 48), which now fails
scripts/__tests__/eager-closure-budgets.test.ts outright rather than needing a manual bump.
Move the value import into loadManaged's dynamic `await import`, matching how the rest of
this file's owner loaders defer their leaf modules. Only the managed-local arm reaches this
path, so an ordinary bind never pays for it, same as before -- the wrapper module itself was
simply the wrong side of the eager/lazy line.
* refactor(closure): walk a source tree through a reader seam
The eager-import-closure walker read the working tree directly through fs, so
every consumer could only ask about the checkout in front of it. Closure
computation now takes a SourceTreeReader; the working tree stays the default,
and a committed git tree answers the same four questions for any tree-ish
without checking it out -- one `git ls-tree` for the tracked set and one
long-lived `git cat-file --batch` for the sources the walker can reach.
Per-tree memoization of package directories and direct edges, plus a
content-keyed parse cache, keep a second tree paying only for what differs.
* chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings
The 202 façade and 6 hub numeric pins are gone. The six platform façades stay
exact at one module, every other existing entry may evaluate no more than the
same file evaluated at the merge-base with origin/main (renames followed), and
an entry that did not exist there fits a per-category ceiling derived from its
path, or carries an APPROVED_OVER_CEILING row naming issue, reason and owner.
Shrinking now needs no gate edit, and a stale approval fails. The standing
denial -- a façade closure never reaches a concrete platform implementation
before discovery or binding selects an owner -- is unchanged.
* chore(gates): scope stale approvals to introduced entries and keep readers in sync
Address review findings on the eager-closure merge-base ratchet.
- docs/agents/testing.md: drop the new bullet. The file was 386 bytes over the
10,000-byte focused-doc budget, and the gate module's header already owns the
invariant, so the prose was duplication the ownership rule forbids.
- The closure walker's relative resolver no longer tries a .tsx suffix. The repo
defines a production source as .ts (tracked-sources.ts pathspecs and
isProductionSourceFile), so the committed-tree reader never loads .tsx content;
resolving one produced an edge that reader could not read, crashing the ratchet
instead of failing it.
- The APPROVED_OVER_CEILING staleness check now looks only at entries still
first-introduced. Once the merge-base carries an entry, the no-growth rule
governs it and nothing reads its row again, so the row is stale for the same
reason a shrunk entry's row is.
* test(vitest): record the subprocess-stub kill-criterion outcome
#1823's kill criterion was met (~64 consecutive genuine Coverage-job
completions since dbc4f2f955 with zero timeout-shaped failures), so
the subprocess-stub project is gone for good rather than mid-experiment.
Rewrite the vitest.config.ts comments to state that resolved outcome
instead of framing it as an ongoing revert-on-first-failure trial.
* test: remove retired subprocess project traces
* test: model the active fuzz worker project
* chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch
The three hash-named raw results under scripts/ios-snapshot-benchmark/evidence/
are measurement output, not fixtures. They now live on the orphan branch
evidence/ios-snapshot (commit 2d4baf461a); the in-tree README records each
file's sha256 and the fetch recipe, and fetched copies are gitignored there.
scripts/ios-snapshot-benchmark/evidence.ts validates an evidence directory
against the raw-result schema and the published hashes
(pnpm bench:ios-snapshot:evidence -- [--evidence-dir <dir>]). Its tests run
on a two-cell excerpt of the warm/relaunch result and skip the corpus check,
naming the fetch command, when the directory holds no evidence.
* fix(bench): require the full published evidence corpus and pin an immutable ref
runEvidenceReport accepted any nonempty schema-valid directory, so the
default directory could pass with only one of the three published
files present. The default directory now names any missing published
filename(s); an explicit --evidence-dir stays permissive by design.
The evidence/ios-snapshot branch tip is mutable and was the only
fetchable source. Pin every fetch command and the README to the
annotated tag evidence/ios-snapshot/71fb2483f and the full commit SHA
2d4baf461a instead of FETCH_HEAD.
* fix(bench): stop exporting evidence.ts internals with no consumer
EVIDENCE_TAG, EVIDENCE_COMMIT, and missingPublishedEvidence are used only
inside evidence.ts. runEvidenceReport is the CLI entry point invoked
through the file's own `node evidence.ts` guard, not through an import,
matching the unexported runDeepButtonRule pattern in the sibling
deep-button.ts script.
* test(platform): extract shared fixtures from the Apple perf and Android runtime suites
Move the duplicated host-command routing, app-bundle writers, and ps
handlers behind packages/platform-apple/src/core/__tests__/perf.fixtures.ts,
and the Android runtime host, device, and ordinary-bind builders behind
packages/platform-android/src/runtime.fixtures.ts. Every test keeps its
title and its own assertions; test and assertion counts are unchanged.
* chore(gates): exclude .fixtures.ts modules from changed-line coverage
runtime.fixtures.ts (packages/platform-android/src) sits outside any
__tests__ dir, so vitest's coverage.include picked it up as production
source and the changed-line gate scored its 20 unreachable construction
lines directly, failing at 39.39% against the 70% threshold.
Add '**/*.fixtures.ts' to coverage.exclude (repo-wide convention:
40+ fixture modules, all test support, never production). The file
disappears from lcov and scripts/coverage-changed/model.ts's existing
excluded-path fallback reports it non-gating instead, with no
duplicate classifier needed there.
Planted red (targeted run against the real diff/model, not committed):
BEFORE (file present in lcov, all added lines uncovered): totalLines=110
coveredLines=0 pct=0 passed=false. AFTER (file absent from lcov post-fix):
totalLines=0 excludedTotal=99 excludedReason=excluded-path passed=true.
Confirmed against a real `vitest run --coverage` + `check:coverage-changed`
pass: runtime.fixtures.ts absent from coverage/lcov.info, gate PASS,
99 lines reported under excluded/excluded-path.
* docs(adr): add ADR-0019 end-state numbers for the daemon composition root
Record the measured daemon top-level file count (199) and entry-to-platform
hop counts (press/Android 38, snapshot/iOS 29) at e624ef9d3f, propose targets
(<=60 files, <=14 hops), and name the src/ zones still awaiting a package
owner, as inputs to a maintainer decision.
* docs(adr): correct ADR-0019 end-state capture-kit inventory and hop counts
PR #2242 review: the capture-kit inventory sentence claimed the package held
only ios-snapshot-engine, but it already owns recording, screenshot/diff,
snapshot quality/occlusion, audio/app-log, and iOS acquisition mechanics per
ADR §1's amendment; replace it with a file-verified inventory of the root
mechanics that actually still lack a package equivalent.
The 38/29 hop counts had no ordered chain, counting definition, or artifact.
Add docs/adr/0019-end-state-hop-trace.md with both routes re-traced
file-by-file at HEAD (24 hops each, matching the independent
maintainability-review-2026-09-02 trace to within one file) and link it from
the ADR. Relabel the <=14 target "proposed, unverified" rather than force a
derivation the snapshot/iOS runner-protocol hops don't clearly support.
* docs(adr): keep hop evidence self-contained
Classify the fixture producer by its platform job (Android Release / iOS
Release) instead of the run's aggregate status: the run flickers to
"queued" in the gap between its fingerprint job and its platform job,
which made the Android consumer bail into an inline build almost
instantly (run 33550746596). Both queued and in_progress on the
platform job now mean keep waiting.
Raise android.yml's wait-for-artifact-seconds to 1800s (matching the
iOS workflow's own budget) so a real wait can play out now that the
premature "queued" bail is gone.
Raise the Android Gradle daemon heap (org.gradle.jvmargs=-Xmx4g) for
both the producer build and the inline fallback build: the producer's
own Android Release job OOM'd in :app:compileReleaseArtProfile (run
33728318738), and both prior inline fallbacks OOM'd in
:app:mergeDexRelease.
* test(android-e2e): make an alert-dismiss failure self-explaining
The Android smoke scenario asserted the post-alert canary text with a
screen-wide `wait text`, whose timeout report is a top-6-label surface
dump that may not include the element in question at all. Assert the
canary through the specific automation-alert-result element instead, so
a failure states its actual current value directly.
Also record the tapped alert button's coordinates alongside its label
(already recorded) in the Android alert-handled result, and capture a
screenshot artifact when an e2e `wait` step times out, so a flake has
more to go on than the surface dump.
* review: extract shared alert-test fixtures, trim narrated docblock
- Extract node/text/button RawSnapshotNode builders (near-duplicated
between alert.test.ts and alert-detection.test.ts) into a sibling
alert-fixtures.ts, reconciling the two button() signatures by keeping
the optional `permission` param. Both suites now import from it.
Planted red: bumped the shared button() rect width and reran both
suites — 2 of 7 tests failed on the changed tap coordinates
(alert.test.ts's dismiss/accept cases), confirming the fixtures are
live-wired; reverted, suite back to 7/7 green.
- Trim captureWaitTimeoutScreenshot's docblock in runtime.ts to the
caller-facing contract only ("never throws, returns undefined on a
failed capture"); the surface-dump rationale already lives in the PR
body's Summary.
Route open/close relaunch scenarios through the production request
router (createRequestHandler + lifecycleDeviceRuntimeGateway) instead
of the bypassed handler-level harness. Tests now bind fake runtime
facts and facets, matching sibling router-level suites, rather than
mocking core/dispatch-resolve.ts and friends directly.
@expo/fingerprint hashes process.cwd() as the project root, but
resolve-artifact-name.sh invoked it from the workspace root: the fixture
cache key was the whole repo's fingerprint, not examples/test-app's native
sources. Run fingerprint:generate with cwd examples/test-app instead; the
script's stdout contract (fingerprint.<hash>.<platform>) is unchanged.
Adds a four-line Catches/Evidence/Cost/Kill-criterion header to every
layering rule module for R2, R4-R7, R9-R14, R16, R18, R19, R65-R73,
and the rule-id uniqueness gate, so each structural check states what
it catches, why no other gate sees it, its LOC cost, and the concrete
condition under which it gets deleted. No behavior change.
The 26 scenarios in session-replay-target-verification-runtime.test.ts each
rebuilt the same temp root, iOS app session, written .ad script, and
request-recording invoke by hand, and repeated the same one-button XCTest
capture literal. Those setup pieces now live as named exports in the
sibling session-replay-scenario.fixtures.ts (replayScriptScene,
saveButtonCapture, emptyCapture, and the annotation constants), built on the
existing test-utils builders (mkdtempForTestSync, makeIosAppSession) and the
replay runtime fixtures. Every test keeps its title, comments, and expect
lines, so each still reads as its own scenario.
Test count (26) and expect count (128) are unchanged. jscpd
(--min-tokens 80 --min-lines 8) duplicated lines within the suite drop from
1,046 (25 clone pairs) to 125 (6 pairs).
The divergence and repair-empty-tail suites are left for a follow-up: this
extraction alone is ~950 gross diff lines.
All 5 recent failures of the gesture pan-duration smoke replay step were
the replay's open --relaunch running a full xcodebuild build-for-testing
after a spurious runner cache_metadata_mismatch (48-61s build + ~40s
launch), which consumed the 60s per-replay budget before --retries 2
could fire. Pass --timeout 180000 to the test command, which overrides
the script's context timeout=60000 per attempt without touching the
shared .ad file.
The snapshot handler suite repeated its setup inline: the device
literals, the token-fixed daemon request, the bind-counting runtime,
the Android capture reply, the flat text rows, the freshness-window
session with its baseline signatures, and the location-required and
Battery surfaces. Those move to the sibling fixtures module (renamed
from snapshot-handler-fixture.ts to the *.fixtures.ts convention) as
named exports; every scenario keeps its title, its inputs, and its own
assertions.
jscpd (--min-tokens 80 --min-lines 8): 536 -> 58 duplicated lines in
snapshot-handler.test.ts (the two remaining clones are assertion
blocks). Test count 41 -> 41, expect( calls 162 -> 162.
Second pass after #2243: the connect --force family, the proxy open/install
pair, and the provider-allocation, persistence-failure, and proxy-disconnect
scenarios build their setup from writeReplacedProfiles, seedPreviousConnection,
forceConnectFlags, recordedLeaseRelease, and recordedLeaseAllocate. Titles and
assertion lines are unchanged; 50 tests before and after.
The connection suite repeated its setup inline: the fake AgentDeviceClient,
the Metro prepare reply, the temp state directory, and the persisted
connection state literal with its version, profile hash, and timestamps.
Those move to a sibling fixtures module as named exports; every scenario
keeps its title, its inputs, and its own assertions.
jscpd (--min-tokens 80 --min-lines 8): 535 -> 211 duplicated lines,
27 -> 10 clones. Test count 50 -> 50, assert calls 213 -> 213.
* chore(depgraph): stop re-deriving the layering inversion baseline
The report's typeInversionsByPair and the gate's checkTypeInversions run the
same loop over the same resolveImportEdges output, so asserting that the
report reproduces TYPE_INVERSION_BASELINE over the real tree checked one
code path against itself. Replace the tree-wide cross-check with a synthetic
test of the report's own counting rule (raw edges, once per file pair).
* chore(gates): retitle the depgraph gate as the report's model tests
The Layering Guard step no longer claims to agree the report with the gate;
it runs the depgraph model and blast-radius tests, which the gate manifest
requires a registered check to own.
* docs: clarify inversion ratchet ownership
R14 (src-utils-retirement) and R71 (replay-ownership) were the same
path-prefix denylist instantiated in two modules. Fold both into
scripts/layering/retired-paths-policy.ts, driven by a table keyed by rule
id; ids, messages, inputs (tracked src/utils paths vs production sources)
and check.ts wiring are unchanged.
The exact-length pin map duplicated what the merge-base already records and
made every shrink a two-file edit. The gate now has one rule: a test file over
the 1,000-line tripwire may be no longer than at the merge-base with
origin/main, and no new test file may cross the tripwire.
A sessionless snapshot, diff, alert, settings, wait or is command releases
the local iOS execution host when it completes. That teardown ran for every
iOS device, including a provider-owned one. For a Limrun lease the device id
is `limrun:ios:<leaseId>` with `kind: 'simulator'`, so `closeIosApp` drove
`xcrun simctl` on the host against a device the host does not own, with retry
backoff on each failure.
`ensureDeviceReady` already declines local readiness work for a provider-owned
device. The teardown side was not symmetric. Apply the same guard, which covers
all four call sites of the helper.
The cost was about 5.8 seconds per sessionless command against a Limrun iOS
lease. It made `test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts`
exceed the 5000 ms default timeout on macOS; that file now runs in 294 ms.
CI did not catch it because the provider-integration lane runs on ubuntu,
where `xcrun` does not exist and the calls fail immediately.
* fix(android): apply settings airplane through the connectivity service
settings airplane wrote airplane_mode_on and then broadcast
android.intent.action.AIRPLANE_MODE, which Android refuses for non-system
callers. The write landed, the broadcast failed, and the device reported
airplane mode with the radios still up.
The connectivity service now owns the change: it is read to prove the build
supports airplane mode before anything is written, driven with
cmd connectivity airplane-mode enable|disable, and read again so the response
reports the mode connectivity holds rather than the one requested. Builds
without that command are refused unmutated with UNSUPPORTED_OPERATION.
Closes#2223
* test(android): pin the mechanics eager closure at 178 modules
Splitting the airplane owner out of settings.ts adds one module to the
mechanics facet, which is implementation-eager by design. The row moves to the
measured number in the PR that grows it.
* fix(android): report only capability absence as unsupported airplane mode
An unrecognized nonzero probe — a permission denial, a connectivity-service
error — was answered with "requires Android 11; use a newer device". Only the
prose adb prints when a build ships no shell implementation for the command
now selects UNSUPPORTED_OPERATION; every other failed read stays
COMMAND_FAILED with its classified hint, and the write is unreachable from
both.
The predicate that reads that prose already existed for the clipboard service
and is now named for the question it answers, so airplane mode reuses it
instead of adding a second message sniff.