1829 Commits

Author SHA1 Message Date
Michał Pierzchała 80997b6bf1 fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal (#2315)
* fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal

Two CI flake families on main and PRs since 2026-09-03.

iOS Smoke, `is absent ... capture was truncated` (7 of 13 failures): the
runner's stampedSnapshotPayload set `truncated: true` on every non-healthy
capture, so a complete private-AX tree taken while the XCTest channel was
penalized as slow (the normal state on a loaded CI host) was reported as
truncated. Nothing consumed that until the strict absence assertion (#2245)
refused truncated captures. `truncated` now tracks completeness only:
payload truncation, a depth-limited capture, or a sparse terminal payload.
The E2E conformance helper asserted the old conflation and now asserts
`truncated === false`; a runner unit test pins the new contract and joins
the targeted list in ios.yml.

Android Smoke, `get text id="automation-alert-result"` selector miss (5 of
5 failures): #2260 replaced a polling wait with a one-shot read right after
`alert dismiss`, and Android's `alert accept|dismiss` returned as soon as
the button was pressed, while the dialog window was still the only thing in
the accessibility tree. They now poll until the same dialog is gone (a
different alert taking its place counts as dismissed), bounded by the
existing action budget, else fail with "did not dismiss the visible alert"
like the iOS runner already does.

* test(provider): model Android dialogs that leave the tree after the alert action

The scripted Android alert scenarios served the same dialog to every
capture, which encoded the old return-after-press behavior; alert
accept/dismiss now confirm the dialog is gone, so a dialog that never
leaves is the failure it should be (covered by a new scenario). The
fixtures now hide the dialog once its button is tapped or Back is sent,
the way the ANR recovery scenario already did.

* test(e2e): wait for the alert outcome before reading it; dump evidence for any failed step

The Android smoke still missed `id="automation-alert-result"` on CI right
after a confirmed dismissal: the daemon opened a fresh helper session for
that read and its 2s capture had no such node, while the same one-shot
read passes locally in 150ms. The fixture's re-render after the button
callback is app timing, so the scenario waits for the outcome text (the
polling landmark #2260 removed) and then pins it to the canary element.

The harness kept only a screenshot, and only for wait timeouts, so the
tree that produced a selector miss was never in the artifacts. Every
unexpected step failure now writes failed-step-N.png and
failed-step-N-snapshot.json next to failed-step.txt.

* test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle

The test-file size ratchet rejects growth in android-lifecycle.test.ts
(1,597 lines at the merge-base), and the dialog re-check work added a
scenario there. The alert scenarios now live in android-alert.test.ts
and the scripted dialog surfaces they share with the ANR scenarios in
android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines.
2026-09-05 23:15:21 +02:00
Michał Pierzchała 4b7c561d1e refactor(commands): author each executable command once in its facet (#2313)
* refactor(commands): let the facet derive its executable definition

* refactor(commands): author interaction commands once

* refactor(commands): author system commands once

* refactor(commands): author capture commands once

* refactor(commands): author management commands once

* refactor(commands): author observability commands once

* refactor(commands): author recording commands once

* refactor(commands): author replay commands once

* refactor(commands): author debugging commands once

* refactor(commands): author react-native commands once

* refactor(commands): author metro commands once

* refactor(commands): author batch commands once

* refactor(commands): author perf commands once

* refactor(commands): state the facet type docs in one line each

* chore(gates): scan facets, not executables, for provider coverage

The integration-progress scanner mapped client calls to command names by
finding defineExecutableCommand blocks. Those are gone; a facet's run
callback now holds the call, and its name may be a module constant.
2026-09-05 23:12:41 +02:00
Michał Pierzchała cf83afb9c9 feat(ios): route Simulator snapshots through AX bridge (#2279)
* feat(ios): route simulator snapshots through AX bridge

* fix(ios): preserve snapshot fallback lineage

* fix(ios): keep regular depth in presentation

* perf(ios): reuse process-verified snapshot targets

* fix(ios): refuse snapshots beneath another foreground owner

* test(ios): bound native setup and isolate runner reset

* test(ios): synchronize helper crashes with request dispatch

* test(ios): exercise foreground guards through native capture

* chore(gates): run native snapshot ownership regression on iOS CI
2026-09-05 23:05:58 +02:00
Michał Pierzchała f1d4efe059 refactor(daemon): inline the touch sub-switch into the interaction dispatcher (#2302)
* refactor(daemon): inline the touch sub-switch into the interaction dispatcher

interaction-touch.ts was a 31-line pass-through re-switching on
press/click/longpress/hover/fill only to call the same five handlers
handleInteractionCommands already imports transitively. Inline the five
cases into the existing switch and delete the file, dropping one hop
from the press/click/longpress/hover/fill dispatch path.

Point the response-construction-guard sentinel at
interaction-touch-press.ts (a file that still exists) so it keeps
watching for a hand-rolled responseData branch across the touch
handler set.

* fix(daemon): close ADR-0011 guard gap and refresh the stale hop trace

PR review findings on #2302:

- The ADR-0011 responseData guard scanned files by an
  interaction-touch*.ts name prefix, which stopped covering the touch
  dispatch switch once it moved into interaction.ts. Added a targeted
  second check that extracts the press/click/longpress/hover/fill case
  bodies from interaction.ts and scans them for hand-rolled
  responseData, without widening the whole-file scan onto
  interaction.ts (which also hosts the unrelated `type` command's
  legitimate local responseData construction). Planted red: reverted
  one switch case to a hand-rolled responseData construction twice
  (press, then click) and reran
  `npx vitest run src/daemon/interaction/internal/__tests__/interaction-response-construction-guard.test.ts`;
  both times the new test failed with the expected
  "Hand-rolled interaction responseData found in the touch dispatch
  switch" message, then passed clean again after reverting.

- docs/adr/0019-end-state-hop-trace.md pinned itself to a commit and
  claimed the press/Android route was untouched by this PR, but the
  PR's whole point is merging two of that table's traced files
  (interaction.ts, interaction-touch.ts). Merged the corresponding
  table rows, renumbered the remaining hops, updated the file/class
  summary counts and every downstream hop-range reference in the same
  doc, and did the same for the one line in
  docs/adr/0019-request-bound-platform-runtime.md that also cited the
  now-stale 24-hop figure.

* test(daemon): hold the touch dispatch switch to structural delegation

The ADR-0011 guard extracted each touch case body with a regex that stopped
at the first nested case/default, so a hand-rolled responseData placed after
a nested switch was invisible. Parse interaction.ts with oxc-parser instead
and require every press/click/longpress/hover/fill case to be exactly one
return await <handler>(...) where the handler is imported from an
interaction-touch*.ts module. Inline planted-red cases cover the nested-switch
shape, an inline literal, a foreign callee, and a missing command.

* style(daemon): oxfmt the interaction response construction guard

* test(daemon): fold the guard's delegation check under the complexity threshold
2026-09-05 22:41:20 +02:00
Michał Pierzchała 0c8227e9b7 refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly

The root host carried two adapters, appInventory and appState, that only
forwarded a platform call back into that platform's own package. Each platform
runtime now performs its own listApps and appState call through a lazy import
inside its package, keeping the deferred load, the AbortSignal threading, and
the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so
Android, Apple and Harmony fixtures no longer stub the two platforms they do
not own.

Android is the one platform runtime whose package now reaches adb directly.
The adb host that adb mechanics require is bound by a module side effect that
only the root can perform, so the Android runtime-module registration binds it
before the module loads. loadAndroidMechanics keeps its own binding import for
the root host ports that reach mechanics without binding a runtime; neither
binder subsumes the other.

Android appstate now runs one foreground-focus loop instead of two. The host
shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter
already closes over its own adb executor, so it calls the executor variant
directly, and that variant took the per-attempt abort check the host variant
had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the
deleted host port and go with it.

Tests: the new ordering test in
src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the
binding import from that registration (order came back
["android-runtime", "adb-host"]); the composed-gateway listApps test in the
same file was seen red by reverting the Android runtime's inlined listApps to a
host.appInventory lookup (TypeError reading 'android'); the new abort test in
packages/platform-android/src/app-state.test.ts was seen red by removing both
signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second
dumpsys was issued and the call resolved). All green after.

* chore(gates): drop the retired app-inventory/app-state host allowances

The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change
deletes, and the ./platform-runtime-app-state-host.ts composition allowance has
no importer left.

* refactor(runtime): construct the Android runtime module with its adb host binding

The Android runtime now calls adb from inside its package for listApps and
appState, which needs the process-wide adb host port bound. That dependency
was hidden in a registry wrapper doing a side-effect import, with a paragraph
explaining why it and loadAndroidMechanics did not subsume each other and an
import-order test pinning the ordering. The package now declares the
dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding
before the runtime loads, and the composition root supplies the one binding
implementation (evaluating its adb host module). The wrapper, the paragraph
and the import-order test are gone; the routed listApps test stays and a
routed appState test joins it.
2026-09-05 22:40:42 +02:00
Michał Pierzchała ba6c818d81 spike(daemon): give the ADR-0014 ref frame private ownership (#2296)
* refactor(daemon): make the ADR 0014 ref frame one owned value

The four `refFrame*` fields on `SessionState` were policed only by the R7
ownership table: any daemon module could write them, and only a full-graph AST
scan could say whose write it was. They are now one `RefFrame` value whose brand
key is private to `src/daemon/ref-frame.ts`, so a module outside that file cannot
construct one and cannot edit the one a session holds; the transitions replace it
whole. Every transition, rejection reason and epoch rule is unchanged.

Readers moved to the accessors ref-frame.ts exports (`refFrameState`,
`refFrameScope`, `refFrameEpoch`, plus a new `refFrameTree` and `refFrame`).
`internal-observation.ts` drops its four-field lineage copy and its field-by-field
comparison: frame identity is now one `===`.

Seen red: with the empty-result early return removed from
`markSessionPartialRefsIssued`, the new frame-identity assertion in
session-snapshot.test.ts fails; restored, it passes. A planted foreign writer
module was rejected by tsc (TS2741 missing brand, TS2540 read-only property)
before deletion.

* docs(depgraph): note the ref frame outgrew its R7 row

* refactor(daemon): make the ref frame nominal, not symbol-branded

A symbol brand on a plain object type stops construction from nothing, but not
`{ ...refFrame(session), state: 'active' }`: object spread copies the symbol key,
so any daemon module could mint an incoherent frame (active state, stale tree)
out of a coherent one and it type-checked. Proven before the fix with a throwaway
module doing exactly that write: tsc reported nothing.

The frame is now a class with `#`-private fields behind getters. That makes the
type nominal, so no object literal is assignable to it — the same probe now fails
with TS2739 (`missing #fields, scope, generation, expired`). Construction stays
inside ref-frame.ts, and the four claim sites (ADR 0014, the SessionState field
doc, and the two in the R7 owner table) now say what the type does and does not
judge: it cannot see a whole frame moved unchanged, which is why the R7 row stays.

Expiry is idempotent by identity again. `expired()` returns THIS frame when the
frame is already expired, rather than an equal copy, which is what the lineage
check in internal-observation.ts compares with `===`. Seen red: with that early
return removed, the tightened ref-frame test fails with "Values have same
structure but are not reference-equal"; green with it.

Also: the ADR 0014 stale-ref help sample seeds its epoch through a real frame
activation again, instead of leaning on the pre-frame snapshotGeneration
fallback, and a find test drops a `?? []` that can no longer be reached.

Behavior is unchanged: same frame contents, same transitions, same admission.

* chore(gates): collapse the four ADR 0014 R7 rows into the owned refFrame value

R7's owner table listed `refFrameState`, `refFrameScope`, `refFrameTree` and
`refFrameGeneration` as four fields that had to be written together by one
module; the code now carries them as one nominal value, so the table carries one
row. R10 follows: 19 writer-owned fields to 16, 22 owner claims to 19.

The row itself stays. The type stops construction, editing and spread-derivation
of a frame outside ref-frame.ts, but it cannot judge a whole frame moved
unchanged — clearing the field, or assigning another session's frame — and the
table can. The comments say that rather than claiming full enforcement.

Seen red: a planted `session.refFrame = undefined` in snapshot-session.ts fails
R7 with "owned by src/daemon/ref-frame.ts"; green once reverted.

* style: apply oxfmt

* refactor(daemon): keep ref-frame expiry module-private

`RefFrame` exposed a public `expired()` method, so any module holding a
frame could derive a new valid one and install it through a reconstructed
session record, past the R7 field scan. Expiry is now a static on the
unexported class, reachable only inside ref-frame.ts; the frame's surface is
four getters. A type-level regression pins that no outside module can
construct, spread, edit, or derive a frame (tsc covers src tests, so a
directive that stops erroring fails typecheck).

* test(daemon): hold the three accessor migrations within the size ratchet

Each file grew by exactly its new ref-frame import; one blank line between
mock blocks goes so the files stay at their merge-base length.
2026-09-05 22:32:57 +02:00
Michał Pierzchała 96727a0b42 fix(apple-runner): never compare an unavailable toolchain probe; name the mismatching cache keys (#2306)
* fix(apple-runner): never compare an unavailable toolchain probe

A timed-out or failed `xcodebuild -version` / `xcrun --show-sdk-*` probe used
to fall back to the literal `unknown`, which was memoized for the process and
then persisted into the rebuilt cache's metadata, so every later daemon on a
healthy host mismatched again and paid a full build-for-testing.

Unavailability is now a distinct outcome with no comparable value: only
successful probes are memoized, an unreadable toolchain fails the cache
decision with a retriable typed error naming the probe that could not answer,
and the CI metadata writer refuses to persist a probe it could not read. The
cache_metadata_mismatch diagnostic now lists the differing keys with expected
and actual values instead of only saying the metadata differed.

The runner-source fingerprint moves to the module that owns the runner's
source roots, keeping the cache-metadata module within its size budget without
adding a module to the Apple facades' eager closure.

* refactor(apple-runner): home the fingerprint tests and the rebuild-decision glue

Tests mirror source topology: the runner-source fingerprint tests move with the
function into runner-source.test.ts and call it directly instead of reaching it
through resolveExpectedRunnerCacheMetadata.

The rebuild diagnostic's mismatch details move next to the cache state that
carries them, so runner-artifact.ts — already past the 500-line extract
threshold — gains no behavior.

* fix(apple-runner): memoize only a parsed toolchain fingerprint

runToolchainProbe cached every nonempty zero-exit answer before
parseXcodeVersionOutput could classify it, so a transient malformed
xcodebuild answer stayed cached and every later cache decision in the
process kept failing after the host recovered. The memo now holds the
complete parsed fingerprint per SDK, written only after all three probes
answered and parsed; a failed round keeps nothing, so the next request
re-probes. Tests cover malformed-to-healthy recovery in one process without
resetting the memo, and that a partial round is not kept.

* style(apple-runner): oxfmt the cache-metadata module and its tests
2026-09-05 22:15:14 +02:00
Michał Pierzchała 51bed41381 test(contracts): one typed conformance helper for the runtime-family suites (#2301)
* test(contracts): one typed conformance helper for the runtime-family suites

Extracts the shared binding-rule mechanics that ten *-runtime.test.ts
files repeated by copy into interactor-operation-conformance.fixtures.ts.
Each module keeps its own expected table and calls the helper with it;
the helper owns no expectations.

* chore(gates): add cross-module completeness test for interactor operation conformance

Asserts both directions against the production registry: every catalog
operation has exactly one conformance table naming it, and every named
operation is registered in the catalog.

* docs(contracts): note the interactor conformance gate's coordinated-deletion blind spot

Review on PR #2301 (P2): the two-direction completeness check in
interactor-operation-conformance.test.ts can't catch a catalog row and its
conformance rows being deleted together in one change — both sets shrink in
lockstep and neither assertion trips. No behavior change; documents the
limitation for a future maintainer who wants to close it with an independent
count.

* test(contracts): drive interactor-operation conformance from the catalog itself

The completeness gate read raw test sources and regex-matched operation
literals, so a commented-out row still counted as conformed while nothing
executed it. The three binding rules now live in one test file that walks
INTERACTOR_OPERATIONS and binds through each catalog row's own bind and
label, with the per-operation expectations (interactor method, minimal
input) in a table typed over every catalog operation. A missing, duplicated
or unregistered row fails typecheck and the table-vs-catalog test; every row
is executed by construction. The conformance fixture and the per-facet
conformInteractorOperations blocks are gone; each facet test keeps only its
dedicated tests.
2026-09-05 22:11:28 +02:00
Michał Pierzchała 7a2d48d160 perf: bundle runtime dependencies and report full install size (#2310)
* perf: bundle runtime dependencies and report full install size

* refactor: remove unused size report breakdowns
2026-09-05 21:38:20 +02:00
Michał Pierzchała 9d9e93a321 ci: avoid unrelated Apple runner cache invalidation (#2303) 2026-09-05 21:10:39 +02:00
Michał Pierzchała 2dabe29b14 fix(web): preserve the backend ref so snapshot refs match actionable refs (#2283)
* fix(web): preserve the backend ref so snapshot refs match actionable refs

The web/agent-browser backend mints refs in tree order and skips
non-interactive nodes, so its `@eN` refs are not dense. agent-device was
dropping that ref in `normalizeAgentBrowserSnapshot` and then re-minting a
dense positional `e${index+1}` in `attachRefs`. The ref an agent reads off
the snapshot (dense, positional) therefore did not equal the ref the backend
resolves on the next action (tree-ordered). On the ShopDemo login screen the
username textbox displayed as one ref while the backend's ref for the same
position pointed at the passcode field, so `fill @e3` landed in the wrong
input.

Preserve the backend ref on each web node and make `attachRefs` keep a
node's existing `ref` when present, falling back to dense numbering for
backends that do not mint refs (iOS/Android/maestro are unaffected).

* test(web): cover non-dense refs through routed actions
2026-09-05 21:06:54 +02:00
Michał Pierzchała c82c5840f5 test(daemon): session-open-url-prewarm through the request seam (#2304)
* test(daemon): session-open-url-prewarm through the request seam

Rewrites session-open-url-prewarm.test.ts (16 open/prepare scenarios
covering URL bundle-id inference and iOS runner prewarm timing) to drive
through the production request router (createRequestHandler +
lifecycleDeviceRuntimeGateway, the seam request-router-open.test.ts and
the #2262 session-relaunch-close rewrite already use) instead of calling
the daemon's session handler directly through the bypassed
session-test-harness.ts. Every scenario title and observable assertion
(response payload, session-store state, dispatch context, prewarm call
order, prepare timing/response data) is unchanged; only the entry point
and mock surface moved.

The file no longer imports session-test-harness.ts or
session-command-harness.ts (both still back their other consumers,
untouched). It keeps only the module mocks these open/prepare scenarios
actually reach: platform-apple runner operations, app-resolution, macos,
device-ready, and the Android open-target shim. logPath assertions now
match the real session-scoped runner.log path the router computes
(rather than the harness's single hardcoded daemon.log stand-in), and
the blocked-prewarm error assertion reads the top-level normalized
error.hint field instead of a details.hint that this seam never carried.

* test(daemon): restore dropped bind/inspectFacts assertions in prepare ios-runner tests

PR #2304 review found the router-seam rewrite silently dropped two
assertions the original suite had: that `prepare ios-runner` inspects
facts and binds exactly once on an explicit iOS selector, and that it
inspects facts once but never binds before rejecting a non-Apple
device. The shared `lifecycleDeviceRuntimeGateway` fixture is frozen
and reused across many suites, so it can't be spied on in place.

Add `createLifecycleDeviceRuntimeGatewaySpies()` to
test-device-runtime-gateway.ts: a fresh, unfrozen gateway whose
inspectFacts/bind are vi.fn() spies backed by the same binding logic,
scoped per test so no call counts leak across files. Use it in the two
affected tests to reassert the original call-count expectations.

Planted red: flipped each restored assertion's expected count/call and
confirmed the test failed, then reverted.

Command: npx vitest run --project unit-core -t "prepare ios-runner starts the XCTest runner on an explicit iOS selector" src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts
Message: AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times

Command: npx vitest run --project unit-core -t "prepare ios-runner rejects non-Apple runner devices" src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts
Message: AssertionError: expected "vi.fn()" to be called at least once
2026-09-05 20:49:27 +02:00
Michał Pierzchała 7aa0b8312d test(daemon): session-devices-batch-runtime through the request seam (#2305)
Rewrites session-devices-batch-runtime.test.ts to drive each of its 14
scenarios through the production handler its own subject actually owns
instead of the bypassed handlers/__tests__/session-test-harness.ts:
handleSessionInventoryCommands for the five `devices` scenarios,
runBatchCommands for the seven `batch` scenarios, handleSessionCommands
(the facts/facets harness, unchanged) for the materialized-close
scenario, and handleReleaseMaterializedPathsCommand directly for
release_materialized_paths. Every scenario title and observable
assertion (response payload, error code/details, claimedBy projection)
is unchanged.

The file no longer imports session-test-harness.ts, so it drops the 10
module-level vi.mock calls that harness pulled in as a side effect
(core/dispatch-resolve.ts, the Apple runner/app-resolution/macos
mocks, Android mechanics, host-kit/command, platform-runtime-*) that
none of these devices/batch/close scenarios ever reach. It keeps one
narrow, locally-scoped vi.mock of materialized-path-registry.ts's
cleanupRetainedMaterializedPathsForSession, the one production
dependency the close teardown path imports directly rather than
through inspectFacts/bindDevice.
2026-09-05 20:49:00 +02:00
Michał Pierzchała 006f2d9f60 chore(gates): layering baselines ratchet against merge-base (#2299)
* refactor(layering): ratchet R6, R9 and R10 against the merge-base tree

R6 type-spine inversions, R9's largest type cycle and R10's R7 ownership
pressure now compare the working tree with the same measurement taken over
the merge-base with origin/main, read through the shared committed-tree
reader (one git ls-tree, one git cat-file --batch, no second checkout).
Growth still fails with the same message shape, a shrink needs no edit, and
no change can bank headroom by leaving a number above the tree.

R9's per-zone check gains membership from the reference, so the overflow
message names the file that joined instead of listing the whole zone.

* chore(gates): delete the R6, R9 and R10 pins the merge-base now supplies

TYPE_INVERSION_BASELINE, LARGEST_TYPE_CYCLE_ZONE_CEILINGS, TYPE_CYCLE_BASELINE
and DAEMON_MODULARITY_BASELINE.sessionState were the hand-edited references
these three ratchets compared against. The merge-base measurement replaces
them, so there is no number left to leave above the tree and no entry to raise.
externalDaemonTypesImporters stays: it names files, not a count.
2026-09-05 20:48:46 +02:00
Michał Pierzchała 17e0c8688f test(daemon): one typed conformance helper for the daemon runtime suites (#2298)
Extracts the shared exact-owner-unavailable admission mechanics repeated
across back/home/app-switcher/focus/viewport/orientation/tv-remote/type/diff
runtime test suites into runtime-binding-conformance.ts. Each suite keeps its
own expected table (device, unavailable fact, and any owner-specific refusal
wording); a new cross-module completeness test checks the shared table
against the command registry's runtime-use declarations in both directions,
including the newly-registered swipe command.
2026-09-05 20:47:58 +02:00
Michał Pierzchała a04b587993 chore(layering): derive the contracts export inventory from package.json (#2297)
* chore(gates): derive the contracts export inventory from package.json

R11's package-boundaries test pinned every packages/contracts export
subpath by hand (~115 entries, compared with deepEqual), so every merge
that touched packages/contracts/package.json also had to hand-edit the
pin. Replace it with a structural check derived from the manifest
itself: every exports target must resolve to an existing, tracked
source file, and the number of resolved targets must equal
Object.keys(manifest.exports).length. The must-not-resolve negative
list (Node resolution enforces the exports map at runtime) is
untouched.

* chore(gates): restore exact-membership for the contracts export surface

assertExportTargetsMatchManifest's two checks (targets resolve to
tracked files, count equals manifest.exports keys) both read from the
same manifest.exports object, so a subpath added or removed moves both
sides together and the equality holds regardless -- the widen/shrink
guarantee the deleted ~120-line CONTRACT_EXPORTS pin gave was silently
gone (#2297 review).

Add scripts/layering/contracts-exports.snapshot.json, an independently
committed baseline regenerated by the new
generate-contracts-exports-snapshot.ts, and deepEqual the live export
specifiers against it. A subpath change now fails until the
contributor reruns the generator and reviews the snapshot diff --
"run a script and commit its output" instead of hand-retyping an
alphabetized array, so the original maintenance-burden goal holds too.

Planted red: widened packages/contracts/package.json#exports with a
throwaway "./planted-red-widen" subpath, then separately deleted the
"./wait" subpath; `node --test --experimental-strip-types
scripts/layering/package-boundaries.test.ts` failed both times on the
new deepEqual with the regenerate-and-review message. Reverted before
committing.
2026-09-05 20:47:40 +02:00
Michał Pierzchała a90d95aab7 perf: bundle tar-stream to reduce install footprint (#2286)
* perf: bundle tar-stream to reduce install footprint

* fix: align bundled archive dependencies and simplify config
2026-09-05 20:37:34 +02:00
Michał Pierzchała 0da105e3c3 docs: simplify agent context and resolve conflicting guidance (#2287)
* docs: clarify agent task scope and validation

* docs: remove redundant and conflicting agent guidance
2026-09-05 20:04:40 +02:00
Michał Pierzchała 35362fe517 refactor(cli): let help resolve command aliases itself and retire R12 (#2293)
* refactor(cli): let cli-help resolve the --help alias itself

bin.ts's --help fast path composed
buildCommandUsageText(normalizeCliCommandAlias(helpTarget)) inline, which
let a future edit call buildCommandUsageText raw without anyone noticing
until an alias's help silently dropped back to a full CLI bootstrap (the
regression #1641 fixed). Move the composition into cli-schema/cli-help.ts
as resolveHelpTargetUsageText, so bin.ts just calls one function that owns
its own alias normalization; bin.ts no longer imports the alias registry
at all.

Retargets cli-help-alias-fast-path.test.ts at the new function (same three
cases) and adds a process-level smoke test asserting `tap --help`/`launch
--help` stdout is byte-identical to `press --help`/`open --help`. Seen red
by temporarily removing the `tap` alias from CLI_COMMAND_ALIASES (both
fast and slow paths lose the alias, producing an "Unknown command: tap"
mismatch); green again after restoring it.

Verified manually: `node --experimental-strip-types src/bin.ts tap --help`
stays byte-identical to `press --help`, and `launch --help` to `open
--help`; `rotate --help` still falls through to the retired-command error.

* chore(gates): retire R12 now that cli-help owns its own alias resolution

bin.ts can no longer compose buildCommandUsageText and
normalizeCliCommandAlias incorrectly because it doesn't hold either import
any more — resolveHelpTargetUsageText in cli-schema/cli-help.ts is the only
call site, and cli-help-alias-fast-path.test.ts plus the new smoke-cli
process test pin it. The static R12 checker existed only to prove that
composition from source text; delete it along with its rule wiring in
check.ts (rule function, import, LAYERING_RULE_IDS/LAYERING_RULES entries,
header comment, summary string).

Drops scripts/layering/bin-alias-fast-path.ts (352 lines) and its test
(311 lines). Updates the two stale references left behind:
record-runtime-mechanics-policy.ts's comparison to R12's "delegate to your
single owner" shape, and check-wiring.test.ts's header, which named
bin-alias-fast-path.test.ts as the seam it protects.

rule-ids.ts discovers rule ids by scanning source text rather than a
hand-maintained list, so no entry there needed updating.

Verified: pnpm check:layering green (175/175), including
check-wiring.test.ts and rule-ids.test.ts; pnpm check:quick (lint +
typecheck) clean; scripts/__tests__/eager-closure-budgets.test.ts
(418/418) unaffected, since neither bin.ts nor cli-help.ts sits in any
HUB_ENTRY_FILES or facade closure — both files reach cli-help.ts only
through a dynamic import.

* test(cli): pin the alias help fast path with a coverage-based oracle

The byte-identical stdout test cannot fail when the fast path is bypassed:
src/cli.ts's slow path resolves the same alias and writes the identical
string, so a reintroduced hand-written table in bin.ts (the exact shape of
#1641) would still pass it. Add a second process-level test that runs
`tap`/`launch --help` and `rotate --help` with NODE_V8_COVERAGE set and reads
the subprocess's own coverage report for src/cli/process-entry.ts, the one
module runCli's slow path loads and the fast path never does.

Seen red: forcing the fast path to always fall through to runCli (simulating
the reintroduced-table bug) failed this test (bootstrappedFullCli true where
false was expected) while the byte-identical test stayed green; reverted and
confirmed both green.

* test(cli): restore an independent oracle for alias help parity

The canonical side of "alias help output matches its canonical command" also
called resolveHelpTargetUsageText, so the assertion became self-consistency:
a degenerate normalizer that maps every input to one canonical command would
make aliasHelp and canonicalHelp equal for every case. Compare
resolveHelpTargetUsageText(alias) against buildCommandUsageText(canonical)
(no alias normalization on the canonical side) instead, restoring the
original two-source oracle.

Seen red: pointing resolveHelpTargetUsageText at a degenerate
`return buildCommandUsageText('press')` failed this test
("launch --help" no longer byte-identical to "open --help"); reverted and
confirmed green.

* refactor(mcp): route the help tool through resolveHelpTargetUsageText

server-guide.ts's help tool composed
buildCommandUsageText(normalizeCliCommandAlias(topic)) inline, the same
composition bin.ts held before this PR moved it into cli-help.ts. That left
a second hand-written call site the R12 gate's own kill criterion said had
to be gone before retirement was moot. Call resolveHelpTargetUsageText(topic)
instead; behavior is unchanged (manually confirmed tap/press and rotate
topics still match) since it's the same composition, and no closure/layering
change since server-guide.ts already imports cli-help.ts statically.

* style: apply oxfmt

* test(cli): prove the help fast path for every registered alias

* refactor(cli): make the process entry importable and test it directly

bin.ts ran its dispatch at import time, so the only way to prove that an
alias --help never loads the full CLI was to spawn the process under
NODE_V8_COVERAGE and grep the report for process-entry.ts. That oracle
needed a paragraph to justify; the code was wrong, not the comment.

The dispatch now lives in src/cli/entry.ts as runEntry(argv, modules, io),
with the five lazy imports injected by bin.ts. entry.test.ts drives it with
recording loaders and the real help module: every registry alias prints its
canonical help with only the help module loaded, an unknown topic falls
through to the CLI loader, --version, bare usage, mcp, and startup failures
each have one case. The subprocess coverage machinery, the alias table pin,
and the multi-line comments are gone; the smoke test keeps one
registry-derived byte-identical alias --help check against the real bin.ts.

Seen red: hand-routing long-press and relaunch to the CLI loader inside
entry.ts failed "every registered alias prints its canonical help without
loading the CLI"; restored.
2026-09-05 20:01:21 +02:00
Michał Pierzchała d1b9914d88 refactor(commands): retire the navigation-only type projection (#2294)
* refactor(commands): retire the navigation-only type projection

`commands/system/navigation-projection.ts` built the five navigation client
methods out of a phantom-typed registry: a `unique symbol` brand carrying
Options/Result/required-ness, two conditional types to read them back, and a
mapped type keyed on `clientMethod`. Nothing else ever used the concept, so the
machinery existed to derive five signatures that fit in five lines.

Those five now say what they mean. `BackCommandOptions`, `HomeCommandOptions`,
`OrientationCommandOptions`, `AppSwitcherCommandOptions` and
`TvRemoteCommandOptions` join their siblings in
`packages/contracts/src/client-system.ts`, and `AgentDeviceCommandClient`
declares all 14 methods in one object type. `back` keeps the `--settle` triple
(#1638), and `orientation`/`tv-remote` keep their required options parameter.
The five MCP output schemas move to `mcp/command-output-schemas.ts` beside the
other handwritten ones, byte-identical.

With the projection gone, `defineExecutableCommand`'s third overload,
`ExecutableCommandProjection`, `AnyCommandDefinition.projection`,
`ProjectedCommandOutputSchemas`/`projectCommandOutputSchemas` and the family's
`clientCommandMethods` table have no users either. Removing the table also
removes the `as unknown as` cast the client used to build eight system methods
from it; the client now writes all eight out, typed.

That closes the `commands/system` -> `client` inversion the client-types header
called the one remaining one.

Public API: the five method signatures are unchanged (structural comparison of
the built `dist/src/index.d.ts` before and after: empty diff).
`HomeCommandOptions` is a new published name for the shape `home` already took.

Tests seen red before green:
- `src/__tests__/client-system-commands.test.ts` (new): wired `home` to the
  `app-switcher` daemon command, saw it fail, restored.
- `src/mcp/__tests__/command-tools.test.ts`: dropped `durationMs` from the
  inlined `tv-remote` schema, saw the dispatch-shape assertion fail, restored.
- `src/commands/system/index.test.ts`: made `home`'s options parameter
  required, saw `expectTypeOf` fail under `pnpm typecheck`, restored.

* test(mcp): pin the closed top-level shape of the navigation output schemas

Retiring the projection replaced an identity assert (`schema === projection.outputSchema`)
with a deep-equal over properties/required, which no longer rejected an extra top-level
key such as a stray `description` or `additionalProperties`. The loop now also asserts the
key set is exactly type/properties/required, so the closed shape is pinned by a test again
rather than by object identity.

Seen red once by giving the `app-switcher` schema a description argument, which adds a
top-level `description` key: the new assert failed with `+ "description"`. Green after
removing it.

The `deriveSettleObservationSchemas` docstring cited that deleted identity assert as the
reason for copying. The press/click shared-object half is the real reason and is all that
remains.

* chore(gates): drop the retired projection from the R6 inversion rationale

The R6 baseline numbers are unchanged (5 inversions, commands -> client still 3):
retiring the projection removed a client -> commands edge, which the ratchet does
not count. What changed is the ARGUMENT next to those numbers. The commands/mcp ->
client bullet justified itself with a zone-level cycle (client-types.ts imported
ProjectedNavigationCommandClient back out of commands/system/); that cycle no
longer exists, so the bullet now rests only on the port argument that was always
the second half of it. docs/dependency-graph-findings.md §0/§0b/§1 carried the
same claim and the same 'move the navigation-projection types out of commands/'
follow-up, now recorded as answered by deletion.

The blocked-shapes table in §1 now reads eight-at-the-time / three-still-blocked, matching
the struck navigation row directly under it.

* test(mcp): split the navigation schema tests out of command-tools.test.ts
2026-09-05 19:30:21 +02:00
Michał Pierzchała 54df6f3f4b feat(runtime): route managed leases through contained transports (#2285) 2026-09-05 19:10:51 +02:00
Michał Pierzchała fd221b15e1 refactor(contracts): build unavailable runtime facts once (#2291)
* refactor(contracts): build unavailable runtime facts from one policy table

freezeUnavailableFacts() classified each of the 34 UnavailablePlatformRuntimeFacts
cells with its own Object.freeze call and its own inline comment, and
createUnavailablePlatformRuntimeFacts() destructured all 38 fields off the
result before spreading them into the operations map. Replace the per-field
freezes with a single UNAVAILABLE_CELL_POLICY table (satisfies
Record<UnavailableCellKey, 'owner-stated' | 'inherits-network'>) and a loop, so
a new cell added to UnavailablePlatformRuntimeFacts must get a policy entry or
the file fails to type-check. The scattered per-field comments collapse into
one block comment above the table citing #1873. Drop the 39-line destructure;
createUnavailablePlatformRuntimeFacts now reads the frozen record's fields
directly.

Add createFullyUnavailablePlatformRuntimeFacts(), an exported constructor that
points every cell (lifecycle included) at one unavailability reason, for
missing-owner cases that have no per-family classification of their own.

Verified the table forces every cell to be classified: flipping `apps` from
'inherits-network' to 'owner-stated' (an optional cell most local runtimes
still leave unclassified) is not a type error, since the table only governs
runtime fallback and the field stays optional either way — but it does turn
into a real test failure: packages/platform-linux/src/runtime.test.ts's
`listApps` assertion goes from `{ available: false }` to `{}`, because the
loop now treats an unclassified `apps` cell as owner-stated and stops falling
back to the network gap. Reverted before committing.

* refactor(gateway): reuse the shared fully-unavailable facts constructor

unavailableProviderBinding() and unavailableProviderFacts() each hand-spelled
every UnavailablePlatformRuntimeFacts cell for a provider mode with no
registered module. The two lists had already drifted: the binding map omitted
`readiness`, the facts map included it — harmless only because both fall back
to the same 'unsupported-provider-mode' reason today, but nothing would have
caught a real divergence. Both become one-liners over
createFullyUnavailablePlatformRuntimeFacts(), so there is exactly one place
that enumerates "every cell is this one reason", and unavailableProviderLifecycleFacts()
is no longer needed.

Added a regression test asserting bind() and inspectFacts() produce
byte-for-byte equal facts for the same unregistered-provider device: it
passes today (confirmed against the pre-refactor gateway.ts, restored
temporarily to check) since the drift was reason-compatible, but it now pins
that equivalence so a future cell added to only one of the two paths fails
loudly instead of silently drifting again.

* refactor(contracts): derive unavailable-cell fallback from optionality

UNAVAILABLE_CELL_POLICY hand-duplicated which cells inherit the
network gap and which are owner-stated, restating exactly what
UnavailablePlatformRuntimeFacts's optional vs required properties
already say. Nothing checked the two against each other, so a
misclassified entry passed `satisfies Record<UnavailableCellKey,
'owner-stated' | 'inherits-network'>` and the cast at the read site
turned it into `Object.freeze({ ...undefined })` = `{}` at runtime -
a fact object missing `available` entirely.

Replaced the two-value table with a key-only UNAVAILABLE_CELLS list
and one `mapUnavailableCells` helper shared by both call sites.
freezeUnavailableFacts now reads `unavailable[cell] ?? unavailable.network`,
which TypeScript resolves without a cast because the union already
covers the optional case - a wrong classification is impossible to
express, not just checked for.

Seen red once: reverted the `?? unavailable.network` fallback (kept
`unavailable[cell]` alone) and reran
platform-runtime-unavailable.test.ts - the new `listApps` assertion
failed with the exact `{}` malformed-fact shape the policy-table bug
could produce. Restored the fallback and it passes.

Also added the missing inherits-network assertion itself
(platform-runtime-unavailable.test.ts): the existing test only
exercised owner-stated cells, so the inheriting path had no direct
coverage in this file.

* test(gateway): fold bind/inspect parity into the existing fixture

The parity test duplicated the whole gateway construction (single
apple module, inline webdriver ProviderDeviceRuntime literal) from
the test directly above it instead of reusing it. Appended the
inspectFacts() call and the equality assertion to that test instead,
and switched toEqual to toStrictEqual: toEqual ignores
undefined-valued keys, and an omitted cell is exactly how the two
facts maps could drift from each other.
2026-09-05 19:05:58 +02:00
Michał Pierzchała d23346c1a6 refactor(cli): derive the common flag readers from the common-field table (#2292)
* refactor(cli): derive the common flag readers from the common-field table

commonInputFromFlags and selectionOptionsFromFlags in
src/commands/cli-grammar/common.ts each hand-restated the common input
key list as object literals. A key added to the common-field table in
commands/common-input-fields.ts (the schema/read/clientKey/audience
source of truth) had no structural link to either flag reader, so
keeping a new key flowing through the CLI depended on remembering to
touch both literals by hand (the failure mode #1304/#1305 hit for
--no-record).

Add a flagKey/flagIn column to CommonInputFieldSpec: flagKey names the
CliFlags property when its spelling differs from the row (only
deviceTarget, which reads flags.target), and flagIn lists which of the
two flag-derived projections ('input', 'selection') the row joins.
commonFlagProjection(flags, projection) walks the table once and
reuses the existing clientKey renaming (deviceTarget -> target) for
the 'selection' projection, matching commonToClientOptions's existing
convention. commonInputFromFlags and selectionOptionsFromFlags become
thin wrappers over that projection; COMMON_COMMAND_SUPPORTED_FLAG_KEYS
in flag-groups.ts is untouched, per its own documented reason for
staying separate.

Added src/commands/cli-grammar/common.test.ts as a characterization
test before touching the implementation: it feeds a CliFlags fixture
with every common flag set, and one with none set, and asserts the
literal objects both functions return. It passed unmodified against
both the old hand-written implementation and the new table-derived
one, proving the projections are byte-identical.

Validation: pnpm vitest run --project unit-core src/commands (575
tests, all green); pnpm check:quick (lint + typecheck) clean;
pnpm check:layering clean (cli-grammar/common.ts importing
commands/common-input-fields.ts is an intra-commands-zone import, not
core/daemon reaching into commands, so R2 commands-floor does not
apply).

* fix(cli): require a typed flagKey wherever a common row joins flagIn

Reviewer findings on the common-flag-reader refactor (9074367e):

- P2: commonFlagProjection cast flags to Record<string, unknown> and
  fell back to `field.flagKey ?? key`, so a row could declare
  `flagIn` with a key that is not a CliFlags property at all (cwd,
  debug) and compile cleanly while reading undefined forever.
  CommonInputFieldSpec now requires `flagKey: keyof CliFlags`
  whenever a row declares `flagIn`; the cast and fallback are gone
  and commonFlagProjection reads `flags[field.flagKey]` directly.
  Verified red/green: temporarily adding `flagKey: 'cwd'` made tsc
  reject the table; reverted after confirming.

- P2: comments in common.ts and common-input-fields.ts claimed the
  table made a dropped common key "impossible" / that flagIn "rides
  structurally". A new common row that omits flagIn still silently
  joins neither projection, uncaught by any compiler check -- the
  flagKey fix narrows the failure mode, it doesn't close it. Reworded
  both comments to say what the table actually guarantees.

- P3: common.test.ts's ALL_COMMON_FLAGS fixture left daemonBaseUrl,
  daemonAuthToken, tenant, runId, and leaseId unset, so a row wrongly
  gaining `flagIn: ['input']` for one of them would still pass --
  compactRecord drops the unset value either way. Added
  NON_PROJECTED_COMMON_FLAGS with concrete values for those keys so
  such a row now surfaces as an extra key. Verified red/green:
  temporarily adding `flagIn: ['input']` to daemonBaseUrl failed the
  test; reverted after confirming.

- P3: flag-groups.ts's comment said the common-input-fields table
  declares only envFlagKeys; it now also declares flagKey/flagIn.
  Amended to name both columns.

Deferred one P3 (selectionOptionsFromFlags's cast to SelectionOptions)
with a note in .tmp/common-readers-pr-body.md -- fixing it would
import cli-grammar/types.ts back into common-input-fields.ts, reversing
the documented one-way dependency for a cast the characterization test
already pins.

No layering, fallow, or gate-manifest surface touched, so no separate
chore(gates) commit.

Checks: pnpm vitest run --project unit-core src/commands (575 tests,
green) and pnpm check:quick (lint + typecheck), both clean.
2026-09-05 19:05:00 +02:00
Michał Pierzchała 09c1caeb7b feat(daemon): add managed allocation operation journal (#2284)
* feat(daemon): add managed allocation operation journal

* fix: harden allocation journal recovery

* refactor: share durable file publication seam

* fix: preserve host-kit import locality

* fix: keep directory sync helper private
2026-09-05 18:35:12 +02:00
Michał Pierzchała fa06c8c6e9 fix(daemon): fence managed readiness behind runtime admission (#2280) 2026-09-05 07:53:07 +02:00
Michał Pierzchała 5bb3ea3b2a feat(ios): productionize Simulator AX snapshot bridge (#2277)
* feat(ios): productionize Simulator AX snapshot bridge

* fix: address Simulator AX bridge review comments

* docs: refresh Simulator AX evidence

* fix: address new Simulator AX bridge review comments

* docs: record public snapshot source timings

* fix: preserve size report helper on base checkout

* fix: allow base packages without snapshot bridge

* fix: close simulator snapshot source ownership gaps

* docs: explain simulator bridge language choice
2026-09-04 13:56:22 +02:00
Michał Pierzchała 17b6ca36f8 test(coverage): rename-only hunks owe no changed-line coverage (#2248)
* test(coverage): rename-only hunks owe no changed-line coverage

Pass --find-renames=90% to the changed-line diff so rename detection no
longer depends on the host diff.renames setting: a 100%-similarity move
contributes no changed lines and an edited move contributes only the
hunks that differ from its source. Threshold unchanged.

* docs(agents): pure moves carry their tests unchanged

Drops the stale src/daemon/handlers/session.ts over-budget bullet (242
lines on main) to stay under the AGENTS.md byte budget.

* style: format coverage-changed run.ts

* docs(agents): restore the session.ts over-budget rule
2026-09-04 11:58:01 +02:00
Michał Pierzchała 172ee149cf feat(screenshot): add --crop-on to crop captures to a selector frame (#2276)
* 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.
2026-09-04 11:18:57 +02:00
Michał Pierzchała 658f822c40 fix: encode the mcp subcommand in server.json package arguments (#2275)
* 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.
2026-09-04 10:53:04 +02:00
Michał Pierzchała 33084c7748 perf(ios): decide Simulator AX bridge viability (GO, Node-direct guest reader) (#2237)
* 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
2026-09-04 07:38:36 +02:00
Michał Pierzchała 941ca0e7e0 ci: enforce the image-size parser mitigation through a test-app gate (#2269) 2026-09-03 21:36:11 +02:00
Michał Pierzchała 7bf8d8c4a3 fix(remote): materialize test suite artifacts against a remote daemon (#2272)
* 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
2026-09-03 20:25:44 +02:00
Michał Pierzchała 309a5360f5 feat(daemon): add the allocator-held device claim kind (#2263)
* 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.
2026-09-03 19:41:53 +02:00
Michał Pierzchała e882cf9723 feat(runtime): add managed-local ownership and the exact-only managed runtime (#2258)
* 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.
2026-09-03 19:41:53 +02:00
Michał Pierzchała e2ce98556b chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings (#2257)
* 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.
2026-09-03 18:01:12 +02:00
Michał Pierzchała cafaded8f6 fix(security): bump test-app overrides for xmldom, browserslist, decode-uri-component (#2268) 2026-09-03 17:43:18 +02:00
Michał Pierzchała 4946e66078 fix(security): bump fast-uri override to 3.1.6 (#2267) 2026-09-03 17:43:04 +02:00
Michał Pierzchała e3c44ea4c3 test(vitest): retire the subprocess-stub kill-criterion experiment (#2255)
* 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
2026-09-03 17:34:17 +02:00
Michał Pierzchała fbf914b907 chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch (#2251)
* 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.
2026-09-03 17:17:40 +02:00
Michał Pierzchała 3726f027ce test(platform): extract Apple perf and Android runtime fixtures (#2253)
* 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.
2026-09-03 15:58:03 +02:00
Michał Pierzchała 9941330dcc docs(agents): PR diff budget, move PRs, gates commit, validation lifecycle (#2247)
* docs(agents): PR diff budget, move PRs, gates commit, validation lifecycle

* docs(agents): merge-base diff budget, subprocess lane facts, gate-stage validation

* docs(agents): keep volatile test topology at its declaration
2026-09-03 15:06:25 +02:00
Michał Pierzchała 0c3d369567 docs(adr): ADR-0019 end-state numbers for the daemon composition root (#2242)
* 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
2026-09-03 15:06:15 +02:00
Michał Pierzchała 4e9820e46e ci(android): wait for the producer's platform job and keep the fallback build alive (#2261)
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.
2026-09-03 14:30:57 +02:00
Michał Pierzchała 79391375c6 test(android-e2e): make an alert-dismiss failure self-explaining (#2260)
* 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.
2026-09-03 14:30:40 +02:00
Michał Pierzchała b820953886 test(daemon): session-relaunch-close through the request seam (#2262)
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.
2026-09-03 14:25:31 +02:00
Michał Pierzchała a4f625c774 feat: add strict wait absent polling (#2236) (#2264)
* feat: add strict wait absent polling

* fix: keep wait absent coverage gates green

* fix: preserve wait absent restart diagnostics
2026-09-03 14:01:15 +02:00
Michał Pierzchała ed76c9c848 fix(ci): fingerprint the test app from its own directory (#2256)
@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.
2026-09-03 11:19:28 +02:00
Michał Pierzchała a5a7f6dfa1 docs(layering): kill criteria on every rule module (#2244)
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.
2026-09-03 11:13:15 +02:00
Michał Pierzchała 8021503c66 test(replay): extract shared fixtures from the target-verification suite (#2249)
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.
2026-09-03 08:02:35 +02:00
Michał Pierzchała 2371ba9bff feat: add strict native absence assertion (#2245)
* feat: add strict native absence assertion

* fix: address absence assertion review feedback
2026-09-03 08:02:09 +02:00