Commit Graph

1496 Commits

Author SHA1 Message Date
Michał Pierzchała 3908559fe2 fix: report real claim results from daemon stop (#1818)
* fix: report real claim results from daemon stop

`daemon stop` typed `claimsReleased`/`claimsOrphaned` as the literal `[]` and
every path hardcoded them, so a graceful stop that released a device claim still
reported none (#1799 observation 3, #1320 acceptance). Graceful teardown now
records each session's claim outcome — released after a clean teardown, orphaned
when teardown left the claim in place — into the daemon shutdown report, and the
CLI merges them alongside provider releases. Forced and not-running stops stay
empty because they cannot know, and a report written before claim reporting
still reads its provider releases.

* fix: classify daemon stop claim results from the clear outcome

`clearDeviceClaim` deliberately resolves without deleting when the on-disk claim
is no longer the one it acquired, so the shutdown ledger's "the call resolved"
test reported a successor's claim as released — a device the daemon never freed,
counted as freed.

`clearDeviceClaim` now returns a typed outcome (`deleted` | `absent` |
`ownership-changed`) instead of nothing, and the ledger classifies from it:
released only when absence is confirmed, and a new `superseded` bucket for a
claim another owner had already taken over. Superseded is neither released (this
daemon freed nothing) nor orphaned (no claim of ours remains to reconcile), so
folding it into either would break that list's meaning; it also raises a warning
so a device now owned elsewhere cannot pass silently.
2026-08-18 14:32:57 +02:00
Michał Pierzchała 8d0de32ba4 fix(android): let a covering sibling hide only what its content covers (#1808)
* fix(android): let a sibling hide only what its content covers (#1806)

pruneAndroidCoveredSubtrees credited a higher drawing-order sibling with
painting its whole box as soon as it had any content anywhere inside it,
or a label of its own. A full-screen DoraemonKit drag surface holding one
189px floating icon therefore condemned the entire app subtree, and an
empty labelled match_parent placeholder did the same.

Occlusion is now spatial. A subtree's footprint is the bounding box of
what it presents (agent targets and labelled leaves); a sibling is covered
when its footprint lies under a candidate's footprint. A node's own label
is no longer paint evidence: a container's content-desc describes its
children and an empty labelled View draws nothing. Only a touch target
still hides its full box (scrims). Comparing footprint to footprint keeps
stacked screens with matching margins registering as covered.

Live on a Pixel 9 Pro XL API 37 emulator with a DoKit-shaped overlay
added to the test app: snapshot -i went from 2 nodes + the sparse hint to
the full app; helper-XML A/B across home/catalog/form/product-detail
recovered every label with none lost, and non-overlay screens are
byte-identical.

* fix(android): measure occlusion by overlapped area, not bounding box

Review on #1808: a bounding box of two corner controls spans the
viewport, so a transparent overlay with a control in each corner still
acquired a full-screen footprint and could prune the app beneath it.

Footprints now keep their presented rects apart, and coverage is the
overlapped area of the two unions (coordinate-compressed cell sweep).
Scrollables count as presenting their box: they consume touches over it,
which is what lets a real pushed screen (header, scrollable body, footer)
still cover a drawer surface. Adds the disconnected-corner regression.

* fix(android): count what a covered sibling shows, not only what it paints

Fuzzing random sibling trees old-vs-new surfaced the one direction the
footprint model could still regress: a container whose only painted
content is small (one corner icon) but which also carries labelled
containers or testID-only markers was condemned as soon as a touch
surface covered that icon, since markers and container labels are not
paint and never entered the footprint.

Footprints now carry two rect sets. `paints` (touch targets,
scrollables, labelled leaves) is what a candidate can cover with; it
still excludes identifiers and container labels, or the DoKit fix would
unwind. `shows` adds every labelled or identified node and is what a
covered sibling must lose in full. Focusable-only nodes no longer paint
their box either, matching #1733 for descendants as well as siblings.

Adds the marker regression. Re-fuzzed 20k trees: new-prunes-more is down
to 0.14 %, all of the class where everything the target shows lies under
a higher touch/scroll surface. Live captures unchanged.

* test(android): pin that focusability never paints a covering candidate's box

A full-screen focusable wrapper holding one clickable icon is a covering
candidate; the lower app content must survive. Fails when paintsOwnBox
counts focus targets again.
2026-08-18 14:32:16 +02:00
Michał Pierzchała f843dc2df1 fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1) (#1820)
* fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1)

`pnpm gate replay-android` failed 4/8 whenever it ran after the full-tier Android E2E
(replays-nightly run 32107665052, job 95620294899): 05-app-lifecycle, 06-swipe-gestures and
both fixture replays diverged under "A system surface covers the app". The E2E was not the
cause. Reproduced on a pixel_7 / API 36 AVD with the same cutout geometry CI's
`avdmanager --device pixel_7` produces (status bar 136px, not the 63px of a plain 1080x2400
skin):

- `03-scroll-discovery.ad` runs `scroll up 3`. The scroll planner clamps travel to the viewport
  minus a 5% band, so the touch-down landed at y=120 — inside the 136px status bar — and
  pulled the notification shade instead of scrolling. On API 36 the app window is
  edge-to-edge, so the reported viewport starts at y=0 and includes that bar.
- The shade then covered every replay until `04`'s `back` closed it. Native readdir order on
  the runner (03, 05, 06, fixture/02, fixture/01, 04, 01, 02) put four files in that window;
  the last green run (2026-07-30) had 04 right after 03, so the pull was masked.

Fix in the product, not the lane: DEFAULT_EDGE_PADDING_FRACTION 0.05 -> 0.1 in the TS scroll
planner and its Swift port. Every real Pixel has a cutout (5.7% of a Pixel 7's height) and an
iPhone's Dynamic Island status bar is 6.9%, so any saturated `scroll up` opened the shade /
Notification Center for real agents too. Parity vectors updated in both suites plus a Pixel 7
regression vector (1080x2400, amount 3 -> touch-down y=240 > 136).

Second contamination the same order exposed once the shade was gone:
`fixture/02-selector-routes-covered-diagnosis.ad` is a #1715 reproduction recipe that FAILS
BY DESIGN at step 9 (covered-target refusal) and leaves the device in landscape, yet the
gate enumerated `test/integration/replays/android` recursively. iOS keeps gate replays in
`replays/ios/simulator` and fixture recipes in `replays/ios/fixture`; Android now mirrors that:
the six Settings replays move to `replays/android/emulator`, `test:replay:android` points there,
and `fixture/` stays E2E-owned (`full:fixture-replays` already runs 01 by path). android.yml
and the workflow-evidence fixture follow the path; the replay-compat manifest keeps the
historical paths it pins at released tags.

Verified live (Pixel 7 geometry, API 36, --retries 0): control run at main head in CI order
reproduces exactly CI's 4/8; with the fix, `pnpm gate replay-android` 6/6 in both native and
CI order, and `03` leaves Settings on screen (scroll up 3 now touches down at y=240).

* test(scroll): drive the TS and Swift scroll-plan parity vectors from one table (#1820 review)

The two suites hand-mirrored the same vectors and #1820 had to edit both by hand — the drift
class the repo already closes for the tap-point rule via contracts/fixtures/tap-point-policy.json.
The scroll vectors (plus both planner constants, pinned behaviourally on a 1000px axis) now live in
contracts/fixtures/scroll-gesture.json; scroll-gesture.test.ts and RunnerTests+ScrollGesture.swift
iterate it. Verified: vitest 10/10; the four XCTests run on an iOS 26.2 simulator with the unit
flag on (Executed 4 tests, 0 failures).

Also: test/ci/android-workflow-evidence.json says what it guards.

Follow-up for content-safe viewport bounds + discovery order: #1821.
2026-08-18 14:31:54 +02:00
Michał Pierzchała 2b6d04a13e fix: enforce device claims for sessionless device mutations (#1809)
`boot` and `shutdown` never consulted the host-global device claim store, so a
daemon in one state directory could terminate an emulator another daemon held a
verified-live claim on and report success (#1799).

Rather than adding a claim check to those two handlers, this makes the class
unrepresentable: `CommandDescriptor` gains a REQUIRED `deviceClaimPolicy` trait
(#1320's vocabulary), and the request-execution scope enforces it where the
request runtime bindings create a device binding — the one seam through which
any handler can obtain device operations, and already the place per-device
deduplication lives. A `transient-exclusive` command acquires a command-scoped
claim before operations reach the handler, refuses a foreign live claim with the
existing DEVICE_IN_USE/DEVICE_CLAIM_LIVE_OWNER error, and releases in the
scope's finally. Every other policy performs no claim-store I/O, so session-bound
commands keep #1320's non-goal intact.
2026-08-18 14:12:08 +02:00
Michał Pierzchała 4c5f693a03 fix: declare frameworkTier on the hover descriptor (main parity gate red) (#1817) 2026-08-18 13:53:30 +02:00
Michał Pierzchała 1234590a69 fix(ios-runner): reject CGRect.infinite in navigation frame guards (#1816)
topLeadingNavigationFallbackPoint and isTopNavigationControlFrame only
validated width/height, never the origin. CGRect.infinite has a finite
(if absurd, ~-9e307) origin, so it slipped past the isFinite/>0 guard:
the fallback point collapsed to roughly (-9e307, -9e307), and an
unresolved element frame was classified as sitting in the nav header
band.

Extract isUsableNavigationFrame(_:), which adds the isInfinite check
TapPointPolicy.isAllowed already uses for the same rect, and share it
between both helpers.

Fixes #1812
2026-08-18 12:32:04 +02:00
Michał Pierzchała 142d156338 ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7) (#1789)
* ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7)

* fix(ci): skip the runner server entry point in the nightly and validate both test flags

* docs(ci): restate the nightly lane cost and timeout honestly

* docs(ci): stop quoting XCTest counts that drift between commits

* ci(ios): tighten the nightly timeout to the measured suite duration
2026-08-18 12:00:43 +02:00
Michał Pierzchała 0f4f322878 fix: reject selector-shaped wait arguments instead of reading them as text (#1813)
`wait <condition> '<selector>' [timeoutMs]` (e.g. `wait open 'label="Open"' 25000`,
`wait exists 'label="x"' 100`) and any unrecognized `key=value` token used to fall
through parseWaitPositionals' text fallback and wait out the full timeout for
literal text that could never appear on screen — reading as a false "element
absent" instead of the caller's own argument mistake (#1035 is the sibling fix
for click/press/fill/get).

parseWaitPositionals now returns a typed `invalid` variant whenever a positional
token is selector-shaped (a recognized key, or an unrecognized key=value) but the
list doesn't form a valid selector expression, or a valid selector prefix is
followed by unquoted trailing tokens. The message names the offending token,
points condition words (exists/present/appears/gone/disappears) at the selector
form, and always offers the explicit `wait text '<text>'` escape hatch. Bare text
(single- and multi-word) and the explicit `text` keyword form are unaffected.

Excluding `invalid` from the type consumed by selector-runtime's toWaitTarget
makes the remaining kind-by-kind narrowing exhaustive without a runtime fallback
branch.
2026-08-18 11:58:33 +02:00
Michał Pierzchała 801734d433 feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path (#1804)
* feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path

Adds `createAgentDeviceTools()` under a new `agent-device/ai-sdk` subpath,
built from the same command registry the MCP server uses so both stay in
lockstep without a hand-maintained tool list. Introduces a `frameworkTier`
descriptor facet ('core' | 'extended') so the factory can default to a
curated perceive/act loop instead of handing a model dozens of tools.

`ai` is wired as an optional peer dependency, imported lazily inside the
factory rather than at module scope, so importing the subpath itself never
requires `ai` to be installed - only calling it does. The package's own
publishing gate (scripts/lib/shipped-imports.ts) is extended to recognize
peerDependencies as a valid resolution source, since this is the first
optional peer this package has shipped.

Also restructures the AI SDK doc around three tiers (zero-code via
@ai-sdk/mcp, the new typed tool set, hand-written tools) and fixes a stale
`needsApproval` reference in favor of the current `toolApproval` API.

* fix(layering): classify src/ai-sdk as a rank-4 zone

The layering guard requires every src/<folder>/ to be explicitly ranked or
unranked; the new src/ai-sdk/ subpath (added in the prior commit) was left
unclassified, failing CI's Layering Guard job. It sits at the same tier as
client/compat/daemon-server/metro/remote/sdk - a public integration surface
consuming mcp (3) and core (2), imported by nothing else in the tree.

* fix(ci): cover, exempt, and pack the new ai-sdk subpath

Fixes the remaining CI failures on the ai-sdk subpath commit:

- Coverage: src/ai-sdk/index.ts had no dedicated unit test (only manual/
  integration verification), so changed-line coverage sat at 6.9% against
  the 70% gate. Adds src/ai-sdk/__tests__/index.test.ts (core vs 'all' tool
  filtering, session/platform pinning and schema hiding, error
  normalization, toolApproval passthrough) with createCommandToolExecutor
  and createAgentDeviceClient mocked the same way command-tools.test.ts
  does, plus a dedicated missing-peer-dependency.test.ts that mocks `ai`
  itself to throw, isolated to its own file so it doesn't affect the other
  tests' use of the real, installed `ai` package. Changed-line coverage is
  now 29/29 (100%).
- Fallow Code Quality: src/ai-sdk/index.ts and examples/sdk/ai-sdk-tools.ts
  are entry points with no in-repo importer (reached only via package.json
  exports / run directly), and the new subpath's exports are unused
  internally by design - both need the same treatment src/sdk/*.ts and its
  examples already have in .fallowrc.json.
- Integration Tests: test/integration/installed-package-metro.test.ts and
  src/__tests__/package-exports.test.ts each hand-list every published
  subpath and smoke-check it from a real packed install; added ./ai-sdk to
  both so the new subpath is actually exercised, not just silently passing.

* fix(ai-sdk): hide MCP transport/config fields from the model too

createAgentDeviceTools() only removed session and mcpOutputFormat from tool
schemas. stateDir was still model-visible and reached the shared executor
as client configuration, letting a tool call redirect into a different
daemon state directory - defeating the "one pinned session" guarantee the
factory exists to provide. includeCost and responseLevel are MCP
tool-config knobs in the same category, irrelevant to this adapter.

Widens the hidden-field set to session/stateDir/mcpOutputFormat/
includeCost/responseLevel, and now strips them from the runtime input
inside execute() too (not just the schema), so the guarantee holds even if
a caller bypasses schema validation. The schema-properties filter and the
input filter now share one omitHidden() helper instead of two near-
duplicate implementations.

Addresses the P1 review comment on #1804.
2026-08-18 11:57:34 +02:00
Michał Pierzchała 20458cbcf6 fix(daemon): reject '.', '..' and empty session names at resolveSessionDir (#1815)
safeSessionName only rewrites characters outside [a-zA-Z0-9._-], so the names
'.' and '..' survive unchanged and path.join resolves them to the sessions dir
itself or its parent, the daemon state dir. A remote caller's --session ..
would then land app.log / runner.log / requests/*.ndjson outside the sessions
tree.

SessionStore.resolveSessionDir is the one place a session name becomes a
directory (AGENTS.md: session artifact paths come from session-store), so it now
refuses such a name with INVALID_ARGS. Every request goes through it first
thing in createRequestExecutionScope, before any artifact path is used, so this
is also the admission-time rejection; every other caller passes an already
admitted name.

isSafeSessionSegment mirrors the predicate PR #1814 adds for its
request-diagnostics route; whichever lands second takes the trivial merge.

Regression tests were proven red against the pre-fix code: resolveSessionDir
returned the sessions dir / state dir for '.', '..', '' and the request scope
resolved runnerLogPath to <stateDir>/runner.log.
2026-08-18 11:57:10 +02:00
Michał Pierzchała 04613ae8d3 ci: keep Bundle Size job green on transient GitHub comment failures (#1795)
* ci: keep Bundle Size job green on transient GitHub comment failures

The size measurement and job summary had already succeeded on PR #1789
(run 32050847506) when the PR comment write got a 503 during a GitHub
incident and failed the whole lane.

--post-comment now retries 5xx / 429 / network errors (4 attempts,
1s/2s/4s backoff) on both the list and write calls. If it still fails,
it prints a ::warning::, appends a note to $GITHUB_STEP_SUMMARY, and
exits 0. Other 4xx (bad token, missing permissions) stay fatal.

* refactor: split GitHub response classification to satisfy fallow complexity gate

* fix: reconcile uncertain comment creates instead of re-POSTing; add regressions

Retry now wraps the whole list -> write cycle rather than each request, so a
create whose response was lost (network error / 5xx) is re-listed on the next
attempt and turned into a PATCH of the marker comment instead of a duplicate
POST. Splits the retry/classify helpers under the fallow complexity gate.

Adds scripts/__tests__/size-report-post-comment.test.ts (unit-core): spawns the
real script against a stubbed fetch and pins uncertain-create reconciliation,
transient exhaustion (warn + exit 0), and fatal 4xx (nonzero, no retry).
SIZE_REPORT_RETRY_BASE_MS lets the tests skip real backoff.
2026-08-18 11:35:36 +02:00
Michał Pierzchała 8db36299e4 feat(web): add hover command for hover-gated UI (#1783) (#1786)
* feat(web): add hover command for hover-gated UI (#1783)

Add a first-class `hover <x y|@ref|selector> [--settle]` verb, admitted on
web only, that moves the pointer without pressing via the agent-browser
backend (mouse move). It rides the existing targeted-touch pipeline
(ref/selector/coordinate resolution, occlusion/off-screen guards, settle
observation, response builder, recording) through a new optional
Interactor/backend `hover` op that only the web provider implements.

Touch platforms have no hover state: capabilities advertise it on web
only and iOS/Android/Linux reject it at admission with a --platform web
hint; longpress stays the mobile hold-gesture verb.

Closes #1783

* fix(hover): native hoverRef route for web @ref, android coverage pin, revert skill edit

Review follow-ups on #1786:
- hover @ref on web now dispatches through the provider's own element handle
  (agent-browser `hover <ref>`) via a new backend hoverTarget, mirroring
  click/fill's ADR 0011 native-ref path — web ref frames carry no rects, so
  the coordinate route could never resolve them. The shared preflight +
  exact-ref dispatch is extracted into dispatchNativeRefInteraction and used
  by tap/fill/hover; the guarantee matrix native-ref row now lists hover.
- Daemon regression test is production-faithful: rect-less web ref frame,
  scoped provider, asserts no coordinate dispatch. Selector→coordinate and
  provider hoverRef tests added.
- Android emulator coverage summary pin 2/53 → 3/54.
- skills/agent-device/SKILL.md reverted (out of scope, AGENTS.md rule).
- Docs/help disclose that --settle with @ref on web shares click's existing
  limitation; use a selector or coordinates for the settled diff.

* test: drive hover through the apple output guard; cover direct hover dispatch

The provider-integration apple-leak guard partitions every public command
into driven/skipped; hover was neither, which failed Integration Tests and
took Coverage down with it. Drive it (it reaches the Apple capability
refusal, which is scanned like any other error response). Also cover the
direct-dispatch handleHoverCommand seam.

* test(web): drive hover @ref in the provider-backed web scenario

The integration-progress gate requires every public command to be referenced
by a provider-backed scenario. Add hover @ref to the web desktop flow: it
must reach the provider's hoverRef handle (never a coordinate) and be
recorded on the session without fabricated x/y, like click @ref.
2026-08-18 11:32:48 +02:00
Michał Pierzchała 8b0560a51d fix: hide clamped descendants of offscreen iOS rows (#1811) 2026-08-18 11:00:19 +02:00
Michał Pierzchała 37e9d34581 chore(format): ignore .claude/** so oxfmt from the main checkout can't sweep worktrees (#1803)
Running `pnpm format` in the main checkout walks into .claude/worktrees/*/,
where the anchored ignore pattern scripts/maestro-conformance/corpus/** no
longer matches, and rewrites every worktree's conformance oracle corpus
(quotes + trailing newlines; 45 files × 15 worktrees on 2026-08-17 20:59).
.claude/ is only ignored via the user's global gitignore, which oxfmt does
not consult.
2026-08-18 10:13:37 +02:00
Michał Pierzchała 681cad2222 test: assert the specific error code instead of any failure (#1781 B4) (#1790)
Converts the 20 test assertions across the repo that accepted ANY
failure (bare `expect(...).toThrow()`, bare `assert.throws(fn)`, bare
`assert.rejects(p)`) into assertions on the specific AppError `code`
each test is actually about, or — where the propagated error is
genuinely opaque (a mocked upstream failure whose identity, not its
shape, is the point) — identity assertions with a comment explaining
why.

Added a synchronous `assertThrowsAppError(fn, {code, message?})`
sibling to the existing `assertRejectsAppError` helper in
src/__tests__/test-utils/app-error.ts, exported via the test-utils
index, for the two src/ sites that needed it.
packages/provider-limrun and packages/provider-webdriver have no
test-utils dir and cannot import from src/, so those sites use
vitest's `expect(...).toThrow(expect.objectContaining({ code }))` or
an inline `assert.rejects(p, matcherFn)` instead.

Sites converted:
- packages/provider-limrun/src/app-log-runtime.test.ts:153-155
  (bare `.toThrow()` x3 -> `UNSUPPORTED_OPERATION`)
- src/daemon/__tests__/app-log.test.ts:39 (bare `.toThrow()` ->
  message match; plain Error, not AppError, from verified-file's
  identity check)
- src/daemon/__tests__/resumable-upload-range.test.ts:13 (bare
  `assert.throws(fn)` -> `INVALID_ARGS`); also fixed line 19's
  `assert.throws(fn, value)`, a documented Node.js gotcha where a
  string second argument is the failure message, not a matcher, so
  it was equally bare in effect
- packages/provider-webdriver/src/webdriver-client.test.ts:229 (bare
  `assert.rejects(p)` -> asserts the raw AbortSignal.timeout()
  rejection's `name`, since the transport re-throws it unwrapped)
- src/daemon/handlers/__tests__/session-device-claims.test.ts:129,
  151, 174 (bare `assert.rejects(p)` x3 -> identity assertions; each
  test's point is device-claim rollback/retention around an opaque
  mocked upstream failure)
- src/platforms/android/__tests__/settings.test.ts:109 (bare
  `assert.rejects(p)` -> `UNSUPPORTED_OPERATION`)
- src/platforms/android/__tests__/snapshot.test.ts:1071, 1342 (bare
  `assert.rejects(p)` x2 -> `COMMAND_FAILED` + message)
- src/platforms/android/__tests__/touch-helper-session.test.ts:526
  (bare `assert.rejects(p)` -> `COMMAND_FAILED`, wrong-protocol
  message)
- src/platforms/apple/core/__tests__/runner-command-retry.test.ts:472,
  527, 550, 762, 881, 1016 (bare `assert.rejects(p)` x6 ->
  `COMMAND_FAILED` with the recovery-path-specific details/message)
- src/platforms/apple/core/__tests__/runner-transport.test.ts:61
  (bare `assert.rejects(p)` -> identity assertion; fetchWithTimeout
  does not wrap fetch() failures into an AppError)

No repo-wide scanner/lint rule added (explicitly out of scope per
#1781); no test loosened.
2026-08-18 10:13:28 +02:00
Michał Pierzchała ccf64f6797 ci: move parked device replay suites to a dispatch-only workflow (#1781 A1) (#1794)
* ci: move parked device replay suites to a dispatch-only workflow (#1781 A1)

Both full-tier device jobs have failed every scheduled run since 2026-07-24: the
Android suite inside full-tier scenarios that had never executed end to end, the
iOS suite on varying steps. They move to .github/workflows/replays-manual.yml,
which has no `schedule:`, so the schedule stops emitting a guaranteed failure while
the suites stay runnable on demand.

A job-level `if: github.event_name == 'workflow_dispatch'` would have looked the
same and lied: `workflowLanes()` decides `qualifying` per workflow FILE and never
reads job-level `if:`, so the manifest kept reporting replay-android, replay-ios,
and replay-ios-device as scheduled-lane owners — the silent-owner-loss failure the
manifest exists to catch. A separate file is what the file-level model already
reads correctly.

Those three checks now have no pull_request/schedule owner, so they are declared as
MANUAL_ONLY_OWNERS rather than folded into UNPROVABLE_OWNERS, whose claim ("it runs,
this loader cannot see it") is no longer true for replay-android. check:gate-manifest
drops from 48 to 46 wired checks and names the three on every run. Two tests pin it:
a dispatch-only lane is non-qualifying however many gates it declares, and every
manual-only declaration must name a registered check that no qualifying lane owns, so
a re-scheduled lane cannot keep a stale exemption.

* ci: attest manual-only checks against their dispatch lane (#1781 A1)

Review P1: MANUAL_ONLY_OWNERS was a negative allowlist — it proved each entry named a
registered check no qualifying lane owned, but nothing tied the entry to a lane that can
still run it. Deleting a parked job, or its run-gate step, would have left the manifest
green and still printing the check as manual-only: parked coverage silently becoming
deleted coverage.

Each entry now names its dispatch lane, and a new 'manual-only' audit assertion resolves
that name against the derived model: the lane must exist, must still be dispatch-only, and
must still declare the gate. replay-android carries an explicit `opaque` flag because its
gate sits inside the third-party emulator action's `script:` (#1429), so the job's
existence is the whole attestation the model can make — and the flag says so rather than
letting an unreadable lane look like a declaring one.

Four regressions pin both directions: deleting a declaration reports the check as unowned;
deleting the parked job fails with 'no workflow defines'; re-scheduling the lane fails
until the entry is dropped; and a parked lane that loses its run-gate step fails unless the
entry is opaque.

* ci: make manual-only mean dispatch-only, not merely non-qualifying (#1781 A1)

Review follow-up: the attestation checked `qualifying === false`, which is true of any lane
that is not pull_request/schedule. Swapping `workflow_dispatch` for `push` in
replays-manual.yml would have kept the audit green and the checks printed as manual-only,
while the runs nobody starts by hand quietly started themselves on every push.

The lane model now keeps the trigger names instead of collapsing them into that one bit, and
the manual-only assertion requires `workflow_dispatch` and nothing else. Three planted
regressions cover the gap the review named: a parked lane re-triggered by `push` fails, a
parked lane with no trigger at all fails, and the loader test pins that trigger kinds survive
into the model (a push lane reads `[push]`, the nightly reads `[schedule, workflow_dispatch]`).
2026-08-18 09:59:34 +02:00
Michał Pierzchała e3f3a2488e test(android): restore full-tier lifecycle and observability scenarios (#1781 A1) (#1793)
* test(android): restore full-tier lifecycle and observability scenarios (#1781 A1)

The nightly Android job has failed on `click id="automation-request-microphone"`
since the full tier landed: `settings permission reset microphone` runs
`pm revoke`, and revoking a *granted* runtime permission kills the app process,
so the round after an accept clicked into an empty launcher surface.

Reproduced on the pinned CI image (android-36 google_apis_playstore, Pixel 7):
pm revoke leaves pid 4259 alive when the permission is denied and kills it when
it is granted, with NexusLauncher resumed afterwards.

Fixing that exposed the rest of a scenario that had never executed end to end:
the post-revoke readback cold-started on the tabs home instead of Automation lab,
the relaunched Automation lab needed its controls revealed, the Form tab does not
exist on the Automation root route, that section needs the system IME back, and
the IME diagnostic sits above the bottom of the form. The observability scenario
then failed the same way (reveal distances tuned for a taller device) plus an
event-timeline walk whose page was smaller than the events each page read appends.

Validated live against a local Pixel_7_CI emulator (API 36, same profile as the
lane): the full tier now runs bootstrap -> inventory -> automation-system ->
form-input -> keyboard-ime -> capture-close -> lifecycle-system ->
observability-artifacts and stops only in full:fixture-replays.

* test(android): repair the drifted fixture replays and pin the catalog canary (#1781 A1)

Review follow-up. The nested batch regression now checks a sibling card instead of the
notice that owns `dismiss-notice`: resolving a child already proves its parent is present,
so the old target could not fail on its own. Confirmed on a Pixel 7 / API 36 emulator that
`gesture-lab-card` and `dismiss-notice` are on screen together at the scenario's existing
0.3 reveal (both present at 0.2-0.4; the card is gone by 0.5).

Getting a full-tier run to complete then required repairing what the lane had never
executed:

- `01-navigation-scroll.ad` clicked `label="Catalog, 0 new notifications"`. #1543 made the
  cart badge conditional, so the live label is `Catalog` — what the iOS twin already used.
- The catalog scroll canary lives inside the scrolling content, and Android accessibility
  snapshots carry on-screen nodes only, so every state except the initial `top` was
  unobservable: `wait "Catalog scroll: down|bottom|up"` could never pass, whatever the
  swipe coordinates were. `stickyHeaderIndices` pins that one line, which makes all four
  states readable at any offset on both platforms rather than tuning the .ad around a
  canary that scrolls away.
- `gesture-lab-android.ad` started its multi-pointer gestures at y=1040, inside the target
  when the file was last repaired but 90px from its top edge after #1567 moved the card
  (targets now span y=949-1525). The second pointer landed outside the view, which reads as
  "the gesture did nothing". Multi-pointer gestures now start at the target centre, and the
  header comment records the geometry they depend on.

Evidence: the lane's own command (`AGENT_DEVICE_ANDROID_E2E_TIER=full` over
smoke-android-emulator.test.ts) passes end to end on a Pixel 7 / API 36 AVD with a
CI-equivalent fixture APK (cached native + head JS through the same repack the workflow
runs): 9/9 scenarios, 153s.
2026-08-18 09:57:24 +02:00
Michał Pierzchała f45228ae71 ci: skip device lanes for root-level docs-only changes (#1781 A9) (#1791)
* ci: skip device lanes for root-level docs-only changes (#1781 A9)

Add AGENTS.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, LICENSE, and
SECURITY.md to the pull_request paths-ignore block in ios.yml,
android.yml, linux.yml, macos.yml, ci.yml, and size.yml. These
root-level docs files were the only gap left after docs/**,
website/**, and README.md — PRs #1568 (SECURITY.md only), #1697
(CONTEXT.md + docs/adr only), and #1722 (AGENTS.md + docs/) each still
triggered a full 9-15 min macOS iOS run despite touching only prose.

Why each file is safe to ignore for every one of these six workflows:

- None of the four device workflows (ios/android/linux/macos) or their
  composite actions read any of these six files at runtime; the only
  hits from `grep -rln` across scripts/, src/, test/, and
  .github/actions/ are prose comments pointing humans at CONTEXT.md or
  AGENTS.md sections (e.g. scripts/layering/check.ts,
  scripts/wire-compat/run.ts, src/mcp/tool-ref-pins.ts) — never an
  `fs.readFileSync`/`readFile` of the file itself.
- The check-affected selector (scripts/check-affected/model.ts)
  already classifies all six as pure docs: `isDocs()` matches any
  `.md` file plus the literal `LICENSE`, and `docsOwnership()` only
  special-cases `website/docs/docs/commands.md` (unrelated). So these
  files already select zero checks — they only ever produced
  `docsOnlyPaths` entries, never `SelectionReason`s.
- Because they select zero checks, the gate-manifest's path-coverage
  category derivation (`scripts/gate/model.ts` `categories()`, which
  iterates `plan.reasons`) never records a category for them, so
  ci.yml has nothing check-manifest-only that these six files would
  need to keep reachable. `pnpm check:gate-manifest` and
  `pnpm check:gate-manifest:test` both stay green after the change
  (48 checks / 33 lanes, 28/28 gate tests passing).
- size.yml's bundle-size job (scripts/size-report.mjs) measures the
  `pnpm build` dist output and startup timing only — no reference to
  any of these six files. (npm packs LICENSE/README.md into the
  publishable tarball, but that's a `pnpm check:package` node-22.12
  concern in ci.yml's packaged-cli job, which is driven by `dist`
  contents and `package.json`, not by LICENSE/README prose — already
  evidenced by README.md being ignored here since before this change.)

Scope disclosure: `mutation-affected.yml` uses a `paths:` allowlist
(not paths-ignore) so it's structurally unaffected; `test-app-build-cache.yml`
has no path filter at all. Neither was touched.

actionlint and `pnpm check:gate-manifest`/`:test` pass on the changed
workflows.

* test: pin root-doc paths-ignore entries with a regression test

Addresses review feedback on #1791 from thymikee: the docs-only
classifier for AGENTS.md/CHANGELOG.md/CONTEXT.md/CONTRIBUTING.md/
LICENSE/SECURITY.md across ios.yml/android.yml/linux.yml/macos.yml/
ci.yml/size.yml had no regression pin. Neither check:gate-manifest
(only proves a *registered check* is reachable) nor actionlint (only
validates YAML shape) nor generic Markdown coverage would catch a
single dropped entry — e.g. LICENSE reappearing in one workflow's
paths-ignore list but not another's would silently put a full 9-15 min
device run back on prose-only PRs.

test/ci/root-docs-paths-ignore.test.ts parses the six real workflow
files and asserts, using the same matchesGlob the gate-manifest model
uses to decide lane triggering, that each of the six root docs is
ignored by each workflow's pull_request paths-ignore. Registered in
vitest.config.ts's unit-core project next to its sibling
upload-agent-device-artifacts.test.ts (parse-only, no device/subprocess
lane needed).

Verified red on main (all 36 file x doc assertions fail — confirmed via
a throwaway script reading `git show main:.github/workflows/*.yml`)
and green on this branch (6/6). Full unit-core project (873 files /
6641 tests) still passes; check:gate-manifest and
check:gate-manifest:test unchanged (48 checks / 33 lanes, 28/28).
2026-08-18 09:57:09 +02:00
Michał Pierzchała 0d3b7413c5 fix: prevent private AX subtree leaks at source (#1807)
* fix: prevent private AX subtree leaks at source

* fix: preserve values in settle signals

* fix: normalize settle signal semantics
2026-08-18 09:23:39 +02:00
Michał Pierzchała 3651047be8 chore: restore unreleased version (#1805) 2026-08-18 07:50:23 +02:00
Michał Pierzchała 6e33d2c3f7 0.20.10 2026-08-18 00:51:18 +02:00
Michał Pierzchała ed1d44fa62 fix: unify private AX scroll visibility (#1798)
* fix: unify iOS scroll snapshot visibility

* fix: preserve composed scroll hints
2026-08-18 00:47:25 +02:00
Michał Pierzchała 302019563c ci: drop duplicate nightly-macos replay job (#1781 A2) (#1787)
The nightly-macos job in .github/workflows/replays-nightly.yml is
byte-identical in substance to smoke-macos in .github/workflows/macos.yml:
same setup-apple-runner-build call, same macos-helper gate, same
replay-macos gate invoked with --retries 2 --report-junit
test/artifacts/replays-macos.junit.xml. macos.yml already runs this gate
on every pull_request and every push to main, so the nightly copy adds no
additional information — 0/40 nightly runs have ever failed independently
of the PR/push gate.

Verified: check:gate-manifest and check:gate-manifest:test still pass
against the trimmed workflow (no gate lost its CI owner), and the full
check:tooling suite is green.
2026-08-17 19:17:32 +02:00
Michał Pierzchała 0697915b10 0.20.9 v0.20.9 2026-08-17 19:01:32 +02:00
Michał Pierzchała 8b698e8efc fix: stabilize private AX settle snapshots (#1784)
* fix: stabilize private AX settle snapshots

* fix: harden private AX settling
2026-08-17 18:58:12 +02:00
Michał Pierzchała 75a4817789 fix: install dependencies for iOS runner releases (#1785) 2026-08-17 18:32:41 +02:00
Michał Pierzchała 4cfa34bd22 docs: reposition README around mobile app automation for AI agents (#1780)
* docs: reposition README around mobile app automation for AI agents

Lead the README with the category (mobile app automation, testing, and
verification for AI coding agents) and the three surfaces (CLI, MCP
server, Node.js API) so humans and search engines can classify it, then
keep verification and evidence as the differentiator.

- Move per-platform transport/caveat sentences (HarmonyOS HDC/uitest,
  Vega VVD-only) out of the intro into "How it works" and add an inline
  guard comment plus an AGENTS.md rule so new platforms only add a name
  to the intro list.
- Add MCP and Node.js quick starts next to the CLI walkthrough.
- Add a works-with/proof line, a "What to ask your agent" prompt list,
  a product-ladder sentence, and two AEO-shaped FAQ entries.
- Point the cloud/remote row at the remote proxy and device clouds docs.
- Align npm, MCP registry, and docs-site descriptions with the same
  category phrasing.
- Remove em dashes and evaluative filler per the humanizer skill.

* docs: tighten README hero, link proof points, move install above capabilities

* docs: add mobile-MCP FAQ distinction, sharpen hero support line, drop coming-soon

* docs: make the build-on-top audience explicit in README

* docs: trim README repetition and signposting

* docs: close the session in finally in the README Node.js snippet
2026-08-17 17:54:17 +02:00
Emmanuel Chucks 🇬🇭 856ff3886f fix(ios): preserve final-probe xcodebuild diagnostics (#1776)
* fix(ios): preserve final-probe xcodebuild diagnostics

* refactor(ios): split runner startup transport
2026-08-16 16:06:25 +02:00
Michał Pierzchała f378050586 feat(snapshot): attach a fallback screenshot to sparse captures (#1764)
A sparse verdict already tells the caller to use a screenshot as visual truth,
which made that screenshot the guaranteed next command on every unreadable
screen — a second round trip to obey advice we authored. The user-facing
`snapshot` dispatch now takes the shot itself and links the path in its
warnings.

The fallback is deliberately hung off `dispatchSnapshotViaRuntime` and skipped
for internal observations: selector resolution, settle, and wait polling reach
`captureSnapshot` directly, so a wait polling an unreadable screen cannot turn
into a screenshot per poll. A failed shot is swallowed — the verdict's own
warning still carries the manual remedy, so the fallback can never fail the
snapshot that was asked for.

Sparse captures also say when the screen is the app's problem. Only the
`sparse-tree` reason code is evidence about the app: every backend reached the
screen and it published no semantic content, which is the same emptiness
assistive tech gets. `ax-rejected`, `budget`, `no-nodes` and `capture-failed`
are limits of this tool and stay unattributed, so readers are not sent to file
bugs against code that is not broken.
2026-08-16 16:06:10 +02:00
Michał Pierzchała 13c482ce50 fix: preserve compact snapshot action labels (#1773)
* fix: preserve compact snapshot action labels

* fix: narrow iOS implementation cell labels
2026-08-16 15:13:36 +02:00
Michał Pierzchała d8a7d03faf refactor: route application lifecycle through runtime facts (#1759)
* refactor: route application lifecycle through runtime facts

Moves the canonical `open`, `prepare`, `close` and internal `runtime` descriptors
behind package-owned lifecycle bindings admitted from device runtime facts, while
daemon request/session policy and public response construction stay put.

Based on main, which already carries the boot unit, the parametrized cutover gate
and the apps unit. Readiness is package-owned there, so the Apple and Android
bindings call ensureAppleReady/ensureAndroidReady rather than a root readiness
bag; ensureAppleReady gained an onColdBootStart hook so open keeps warming the
runner cache in parallel with a cold boot, and a narrow markBooted port publishes
readiness' fresh observation so a flow still makes one simctl listing.

Cutover rows take R24-R27, clear of the accepted catalog and the sibling install
stack, and cutoverTableDefects rejects a duplicate rule id.

Two defects this unit introduced are fixed here rather than shipped:
`open <app> <url>` dropped the URL on a first open, and test-IME activation was
first fatal on an unobtainable helper and then over-caught. Helper unavailability
is a typed non-activation outcome now; fence, lock and post-record failures
propagate.

The duplication the unit had accumulated is gone: one runtime-admission module
instead of five per-command copies, one direct-lifecycle binding factory instead
of six hand-rolled packages, one transport-hint predicate, one session
finalization path, and no identity-wrapper module.

* fix: allocate lifecycle cutover rows after deployment

* chore: preserve lifecycle union reconstruction

* fix: reconcile lifecycle runtime stack

* refactor: tighten lifecycle runtime topology

* refactor: remove superseded runtime adapters

* fix: preserve stacked runtime cutovers

* test: preserve migrated runtime ownership

* test: move Android deployment retry ownership

* test: extract runtime hint fixtures

* fix: preserve lifecycle stack invariants

* fix: complete lifecycle runtime cutover

* fix: remove lifecycle cutover residue
2026-08-16 15:13:10 +02:00
Michał Pierzchała 66cca1a5b8 refactor: route install commands through platform runtime (#1758)
* refactor: route install commands through platform runtime

* fix: preserve stacked runtime facts

* fix: align deployment facts with shutdown runtime

* refactor: simplify capability facts projection

* style: format capability facts projection

* fix: preserve migrated capability ownership

* fix: propagate deployment artifact cancellation

* refactor: move Harmony deployment mechanics into package

* refactor: move Apple deployment tools into package

* refactor: move Android deployment tools into package

* refactor: inject deployment temporary storage

* refactor: remove superseded deployment helpers

* fix: preserve provider deployment transport
2026-08-16 15:13:09 +02:00
Michał Pierzchała 5855dfc2e0 refactor: route shutdown through device runtime (#1757)
* refactor: route shutdown through device runtime

* fix: cover shutdown cutover review gaps

* fix: propagate shutdown cancellation

* fix: move shutdown mechanics to platform owners

* fix: pass device to shutdown fact fixture

* test: cover shutdown facts in session state fixtures

* test: simplify Android shutdown assertions

* fix: preserve Apple shutdown cancellation
2026-08-16 15:13:08 +02:00
Michał Pierzchała 5b1b1aad2e fix: fingerprint the built daemon's real import graph (#1545) (#1771)
* fix: fingerprint the built daemon's real import graph (#1545)

The static-import regex in computeDaemonCodeSignature required whitespace
around `from` and after `import`/`export`. The built daemon entry is a
minified bundle (tsdown/rolldown `minify: true`), whose real import
statements have zero whitespace (e.g. `import{o as e}from"../foo.js"`), so
the regex never matched any dependency edge and the graph walk silently
degraded to fingerprinting only the entry file (`graph:1`) instead of its
true ~50-file runtime graph.

That defeats the safety net the signature exists for: a daemon started from
one build can look identical to a client running a materially different
build as long as the entry file's own size/mtime happen to coincide, and the
"code-signature mismatch" takeover this check drives was one of the
mechanisms observed in #1545's worktree dev-daemon multiplication.

* refactor: fingerprint import specifiers by shape, not by import grammar

Replaces the two keyword-anchored regexes (one for static import/export/from
clauses, one for dynamic import()) with a single pattern that matches any
quoted, relative-path-shaped string literal. The prior approach had to track
the exact whitespace/keyword shape bundlers emit around a specifier, which is
exactly what broke for minified output in the immediately preceding commit —
formatted source, minified builds, static imports, re-exports, and dynamic
imports all put the same thing in the same place (a relative string literal),
so matching on that directly is both simpler and immune to future formatting
differences. A same-shaped string that isn't really an import is safe to
over-match: it just fails to resolve to a file and gets dropped.

Verified the file count is unchanged against this repo's real build (dist:
graph:50, src: graph:741, both identical to the keyword-anchored fix).
2026-08-12 17:19:46 +02:00
Michał Pierzchała b59b4e5c96 fix(daemon): make request-timeout cleanup and hints platform-aware (#1751)
* fix(daemon): make request-timeout cleanup and hints platform-aware

Every local request timeout ran the Apple-runner xcodebuild pkill cleanup
and emitted Apple-specific hint wording ("Apple runner work was aborted",
"Timed-out Apple runner xcodebuild processes were terminated") regardless
of the session's actual platform, which was misleading on Android/web/
Harmony timeouts.

The client already carries the request's declared --platform on the exact
DaemonRequest object both timeout call sites hold (req.flags?.platform), so
no new plumbing or src/platforms import is needed. When the platform is
known non-Apple, the Apple pkill cleanup is skipped and the hint drops its
Apple-runner claim; when it's unknown, both stay on their historical
fail-safe behavior.

Part of #1739 (independent cleanups)

* fix(daemon): split timeout cleanup eligibility from hint evidence

Maintainer review on #1751 found a real correctness inversion in the
first pass: gating the Apple xcodebuild pkill cleanup on the request's
declared --platform flag is wrong in both directions.

- req.flags.platform is not authoritative for session-bound execution.
  applyStripLockPolicy (request-lock-policy.ts) lets an existing
  session's real device platform silently override a conflicting
  declared selector under --session-lock strip, so a request declaring
  a non-Apple platform can still legitimately execute Apple work.
  Skipping cleanup on that declared flag would skip real cleanup —
  the dangerous direction.
- The common session-bound request omits --platform entirely, so the
  previous fail-safe (undeclared -> treat as Apple) left the hint
  wrongly claiming Apple involvement on the motivating case
  (Android/web/Harmony session timeouts) too.

Redesign: separate the two decisions.

- Cleanup eligibility is unconditional again for every local timeout,
  matching the original pre-fix behavior: the pkill patterns are
  Apple-process-name-specific, so sweeping them on a non-Apple host or
  session matches nothing and costs a few no-op subprocess spawns,
  never a wrong skip. There is no client-visible signal that proves a
  session-bound request cannot touch an Apple runner, so eligibility
  does not try to prove one.
- The hint may only name Apple-runner involvement on evidence this call
  site actually has: an explicitly declared Apple platform selector, or
  the cleanup itself having terminated a matching process
  (appleCleanupEvidence). Anything else gets platform-neutral wording.

Adds production-seam route tests
(src/daemon/client/__tests__/daemon-client-timeout-route.test.ts) that
spy on the real exec seam and drive actual socket/HTTP timeouts through
sendRequest, covering the rebound-session and unknown-session cases a
pure formatter test cannot catch. Proved red against the prior commit
(82aa5ade2) via a scoped git stash before restoring the fix.

Part of #1739 (independent cleanups)
2026-08-12 16:58:13 +02:00
Michał Pierzchała 39cd4d346a refactor: route appstate through platform runtime (#1755)
* refactor: route appstate through platform runtime

* test: keep appstate capability fixture below complexity limit

* test: cover appstate required readiness fact

* fix: align appstate facts with boot readiness

* fix: keep appstate use declaration minimal

* fix: close appstate parity and ownership gaps

* docs: record final appstate size accounting

* fix: merge neutral runtime imports

* docs: align final appstate size totals

* fix: remove stale runtime dependency edges

* docs: correct appstate size accounting

* refactor: keep runtime-use factory internal

* docs: itemize runtime-use relocation

* fix: move appstate queries into runtime packages

* refactor: retire root foreground query paths

* refactor: share Android foreground parser ownership

* fix: preserve Android appstate parser precedence

* docs: keep appstate evidence in review artifacts

* fix: keep appstate runtime loading lazy

* fix: fail closed for stale limrun appstate

* fix: preserve limrun recovery and abort appstate

* fix: narrow limrun exact-owner recovery

* fix: allocate appstate cutover rule

* fix: reconcile appstate with merged main

* style: format harmony runtime test

* fix: allocate appstate rule id

* fix: allocate appstate layering rule

* fix: remove stale app command admissions

* fix: close appstate layering regressions

* fix: align Harmony capability parity with runtime facts

* test: cover limrun recovery-only readiness

* fix: keep Limrun recovery binding app-log only

* fix: parse Android app state in linear time
2026-08-12 16:55:50 +02:00
Michał Pierzchała 9c22467832 refactor(ci): make gate ownership structural (#1429) (#1753)
* test(ci): prove every registered gate is owned and reachable (#1429)

A check that silently stops running looks exactly like a green build. Two
suites had already stopped: `check:tmpdir-leaks` (with its model tests) and
`test:fixture-cache` are real package scripts that no workflow ran, reachable
only through the `check:unit` aggregate CI never invokes.

`CHECK_CATALOG` becomes the registry of every check and `pnpm gate <id>` the
only way CI runs one, so finding what a lane runs is a scan for `pnpm gate`
rather than an attempt to interpret shell. `pnpm check:gate-manifest` then
asserts against the real workflows that every registered check is run by some
qualifying lane (per unit, not per script name), that every check the real
selector activates for a path is run by a lane that path would start (#1420's
class), and that every Vitest project and suite script belongs to a check.

The wiring that keeps those honest is asserted too: a gate id must name a
registered check, an `if:` must be ruled on in GATE_CONDITIONS so `if: false`
unowns what it guards, an action declared to run a gate is proven to, and a
job whose steps the loader cannot open fails closed.

It deliberately does not try to prove CI runs project code only through
`pnpm gate`. Whether a shell block executes project code is not decidable from
its text, so shell this model does not recognise earns no ownership credit —
the failure direction is a check reported unowned, never one waved through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* test(ci): update the two suites that assert on rewired workflow text

`scripts/mutation/workflow.test.ts` and `test/ci/trusted-fixture-artifact.test.mjs`
read the workflow and action files and assert on their command text, so routing
those steps through `pnpm gate <id>` moved what they were matching.

They are the two suites the manifest cannot help with: it proves a gate is still
run, not that a test asserting on how CI spells a command was updated with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* fix(ci): credit gates by execution shape, and keep every guard

Three ways the manifest could report a gate as owned when it does not run.

1. Crediting was a substring scan over `run:`, which #1429 explicitly rules
   out — "do not infer reachability from a command name merely appearing in
   workflow text". `false && pnpm gate x`, a gate inside `if false; then … fi`,
   one named in a heredoc, and `echo pnpm gate x` all credited it. There is a
   live instance: conformance-regenerate.yml's "Fail if regeneration changed
   anything" step names `pnpm gate maestro-regenerate` inside an error message
   telling a human to run it, and that credited the gate.

   A gate now counts only as the first command segment of a line, and a body
   carrying shell structure earns nothing. Reachability inside a script is not
   decidable, so this does not try: unrecognised shape means no credit and the
   check reports unowned. `VAR=$(pnpm gate x …)` is read, since the assignment
   form is unambiguous and the gate runs.

2. Job-level `if:` was not modelled at all, though six live jobs carry one, so
   a job that cannot run still credited every gate inside it. Two conditions on
   the mutation lanes are now declared.

3. A caller's `if:` REPLACED the guard on a nested composite-action step
   (`guard[0] ?? step.condition`), so an outer `always()` erased an inner
   `if: false`. Steps carry every guard between the lane and the step.

Also corrects two source comments that still claimed project code run outside
the runner fails the manifest. It does not: such a step earns no credit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* ci: add the run-gate action that names a gate structurally

The seam the ownership proof will read instead of shell. A lane says which
gate it runs in `with.gate`, a typed input the manifest reads straight out of
the YAML and validates against CHECK_CATALOG.

Nothing here is wired yet — the ~60 call sites and the model change follow.
Added first so the target of that conversion is reviewable on its own.

`args` cannot select which gate runs; it is appended after the id, so the
worst a wrong value does is fail the gate it already named. There is no
`|| true` and no output capture: the gate's exit code is the step's exit code,
so a gate cannot run without being able to fail its lane.

Part of #1429.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* merge: main (#1770) and route its three new steps through the runner

#1770 landed the orphan-check fix on main, wiring `check:tmpdir-leaks`,
`check:tmpdir-leaks:test` and `test:fixture-cache` into Coverage, Layering
Guard and Integration Tests. This branch had wired the same three through
`pnpm gate`, so the merge produced two steps per check rather than a conflict
— each check ran twice.

Kept main's steps, with the placement and reasoning reviewed on #1770, and
changed only their `run:` line to the canonical runner. Dropped this branch's
duplicates. Net effect on CI is unchanged: the same three checks, in the same
three lanes, once each.

Gate manifest green after the merge: 47 checks wired across 33 lanes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* fix(ci): address review — suite detection, freerange, glob, vacuous skip-list

Six review findings plus the mutation blocker.

[bug] `registered` was shape-only, so a `test:*` script running
`node src/bin.ts test <dir>` resolved to a `script:` leaf and was invisible.
Four `test:replay:*` scripts were owned only because someone hand-registered
them; `test:replay:android` was neither registered nor reported while the
nightly ran the same six .ad files by inlining them. A `test:*` script is now
a suite by name. `replay-android` is registered, and the nightly runs the
script instead of re-listing its files so the two cannot drift.

  The nightly invokes it inside `reactivecircus/android-emulator-runner`'s
  `script:` input — shell handed to a third-party action this loader does not
  read — so the suite executes but cannot be credited. Recorded in
  UNPROVABLE_OWNERS with that exact reason rather than assumed.

  The fixed detector also found a second orphan the review did not name:
  `test:integration:progress`. That one is a reporter whose `--check` sibling
  is the registered gate, so it is declared in REPORTING_SCRIPTS — a
  declaration that itself fails when inert.

[bug] `freerange` defaulted to localRunnable, so fail-open ran `fr` (a Bun
binary) on the pre-push path. Now false.

[suggestion] The `--run` skip-list asserted `build:android-snapshot-helper`,
a name `android-helpers` no longer uses, so it could not fail. Derived from
the catalog instead.

[suggestion] `matchesGlob` joined `**` splits with `.*`, making the adjacent
slash mandatory — GitHub's `**` matches zero directories, so
`src/**/*.test.ts` did not match `src/a.test.ts`. Pinned against
`packages/*/src/**/*.test.ts`.

[suggestion] Deleted the unwired `run-gate` action. It had no callers, was
absent from GATE_ACTIONS, and its comment described a system that had not
shipped. It returns with the rewiring, not before.

[suggestion] Collapsed the module headers that narrated discarded designs.

Mutation: `daemon entrypoint publishes HTTP metadata and cleans up on
shutdown` is the only test here that spawns a real daemon process. It takes
~1.1s alone but exceeds Vitest's 5s default inside Stryker's dry run, which
aborts the sweep before a single mutant runs. Given 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* fix(mutation): order sandbox aliases longest-first so subpaths resolve

Every shard of the mutation sweep aborted in Stryker's dry run with:

  Cannot find package '@agent-device/selectors/engine' imported from
    .tmp/stryker/sandbox-*/src/core/selector-pipeline.ts

The alias was generated correctly; it just never won. Vite matches a STRING
alias by prefix and takes the first hit, and `workspaceSpecifierTargets`
emitted the bare `@agent-device/selectors` ahead of the subpath entries. The
bare entry therefore captured `@agent-device/selectors/engine` and rewrote it
to `…/src/index.ts/engine`, which does not exist; Node fell back to real
package resolution, could not find the subpath inside the sandbox, and the dry
run failed before a single mutant ran — so the shard uploaded an empty
envelope instead of a report and the ratchet failed for want of one.

Sorting longest specifier first makes the most specific alias win:

  @agent-device/selectors/engine -> packages/selectors/src/engine.ts
  @agent-device/selectors/ast    -> packages/selectors/src/ast.ts
  @agent-device/selectors        -> packages/selectors/src/index.ts

`/ast` never tripped this because nothing in a related test set imported it;
`selector-pipeline.ts` introduced the first subpath import that mattered
(#1744), so the mutation lane has been unable to run since that landed. Any
PR touching `scripts/mutation/**` — which fails open into the full sweep —
would have hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* refactor: derive gate ownership from workflow structure

* fix: run gates without optional arguments

* fix: resolve mutation workspace subpaths exactly

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 16:02:18 +02:00
Michał Pierzchała c7565cb1f8 refactor(snapshot): clean snapshot ownership (#1754)
* refactor(snapshot): clean snapshot ownership

* fix(snapshot): address ownership review feedback
2026-08-12 13:44:21 +02:00
Michał Pierzchała 07d528086b fix: harden iOS alert smoke scenario (#1767)
* fix: harden iOS alert smoke scenario

* fix: address iOS alert smoke review feedback

* fix: present fixture alert after React commit
2026-08-12 12:54:23 +02:00
Michał Pierzchała 85aef7af75 fix(ios): make custom-action reads atomically single-flight (#1749)
* fix(ios): relax custom-action refusal timing assertion

* fix(ios): make custom-action admission atomic
2026-08-12 12:53:49 +02:00
Michał Pierzchała 15133b587f fix: validate macOS recording finalization (#1732)
* fix: validate macOS recording finalization

* fix: preserve local macOS recording ownership

* fix: resolve recording teardown session keys
2026-08-12 12:51:13 +02:00
Michał Pierzchała 3eda37b0e0 fix(ci): run the three checks no workflow was running (#1770)
`check:tmpdir-leaks`, `check:tmpdir-leaks:test` and `test:fixture-cache` are
real package scripts with real assertions that no CI lane executed. All three
are reachable only through `check:unit`, an aggregate no workflow invokes, so
a regression in any of them could not fail a PR — and a check that silently
stops running looks exactly like a green build.

Verified by transitive script reachability over every workflow and composite
action on main: zero references to any of the three anywhere in .github.

Placement:
- `check:tmpdir-leaks` joins Coverage, the lane whose instrumented suite is
  what would leak a run directory.
- `check:tmpdir-leaks:test` joins Layering Guard rather than sitting next to
  the check it covers. vitest-tmpdir-global-setup.test.ts proves the lifecycle
  by spawning a real nested `vitest run`, and Coverage already loses runs to
  worker-fork teardown errors; starting a nested Vitest beside the full
  instrumented suite is a contention risk with nothing to gain.
- `test:fixture-cache` joins Integration Tests, beside the fixture-app
  fallback smoke that covers the same artifact contract.

All three pass at this commit, so this wires green gates rather than red ones.
`check:tmpdir-leaks` was also shown non-vacuous: planting an abandoned
agent-device-test-run-* directory makes it fail and name the directory.

Part of #1429, which stays open for the ownership proof that would have caught
this class automatically.


Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 12:35:28 +02:00
Michał Pierzchała f98814ff10 fix: preserve recording evidence across fault boundaries (#1734)
* fix(recording): preserve native evidence across fault boundaries

* fix(android): retire evidence after active publish rollback
2026-08-12 12:11:37 +02:00
Michał Pierzchała c9e6df1d6f test(cli-help): gate every help example against the CLI schema (#1766)
Help is the agent-facing command contract, and agents copy its example lines
verbatim, so an example the parser rejects is a broken command surface. `help
react-native` advertised `open --metro-host/--metro-port` from 0.16.x until
`open` gained the flags, and the same drift was still live in `help macos`,
which showed `snapshot -i --platform macos --surface menubar` even though
`--surface` is an `open`-only flag the session then keeps.

Add a conformance test that reads every example back out of the rendered help
(root usage, every topic, every command) and runs it through the production
parser plus the command's CLI reader. Lines carrying placeholder syntax in an
unquoted token are synopsis shapes and are skipped; everything else must parse.

Fix the macOS menu-bar example and reword the two prose lines that opened with
`agent-device ` but were not commands, so the rule stays total.
2026-08-12 12:10:19 +02:00
Michał Pierzchała b990271580 fix(ios): use current devicectl capture-screenshot syntax for physical devices (#1769)
The direct-capture fallback ran `devicectl device screenshot --device <id> <path>`,
which current Xcode devicectl rejects with "Unknown option '--device'", forcing
every physical-device screenshot into the slower XCTest runner path. Xcode's
devicectl now requires `devicectl device capture screenshot --device <id>
--destination <path>`.

Fixes #1760
2026-08-12 12:08:58 +02:00
Michał Pierzchała eabc936a0f refactor: route apps through request runtime (#1756)
* refactor: route apps through request runtime

* test: remove stale apps adapter mock

* fix: clean apps runtime replay artifacts

* fix: remove stale runtime test exports

* refactor: simplify apps runtime admission

* test: exercise runtime use through facade

* fix: close apps runtime admission gaps

* fix: disambiguate doctor app inventory callback

* fix: keep capability fixture below complexity limit

* fix: keep HarmonyOS app inventory fail-closed

* test: type HarmonyOS app admission fixture

* fix: restore HarmonyOS app inventory parity

* test: align HarmonyOS readiness fixture

* fix: preserve HarmonyOS doctor app parity

* test: type HarmonyOS doctor fixture

* fix: isolate HarmonyOS doctor policy

* fix: align apps cutover with shared rule catalog
2026-08-12 11:55:09 +02:00
Michał Pierzchała b13c06338e refactor: route boot through readiness runtime (#1747)
* refactor: route boot through readiness runtime

* fix: separate boot admission from readiness

* fix: register boot cutover policy

* refactor(runtime): move readiness into platform owners

* fix(test): tolerate provider temp cleanup races
2026-08-12 11:55:09 +02:00
Michał Pierzchała ba48146520 refactor(layering): one parametrized runtime-command-cutover gate (ADR 0019 §8) (#1745)
* refactor(layering): fold the four cutover policies into one parametrized gate

ADR 0019 §8: the per-command cutover gates consolidate into one parametrized
runtime-command-cutover gate driven by a table of migrated commands. Adding a
migrated command adds a row; the mechanism carries one planted-red proof instead
of one per command.

Part of #1739 (wave 0)

* fix(layering): scope cutover calls to lexical owners

* chore: format cutover ownership model
2026-08-12 11:55:08 +02:00
Michał Pierzchała 8f98d23f14 refactor(layering): give each colliding rule id its own number (#1750)
* refactor(layering): give each colliding rule id its own number

R11 and R13 each named two unrelated rules. report() groups violations by the
rule string and titles every annotation `Layering drift (${rule})`, so a shared
number made the guard's output ambiguous about which rule fired.

Reference counts decided which rule keeps its number. R11 package-boundaries is
named in ~30 places (CONTEXT.md, ADR 0019, testing.md, the mutation and
affected-check configs, four package source comments, its own tests) against one
for the contracts rule; R13 platform-package-substrate is the RULE in three
policy files plus CONTEXT.md, ADR 0019 and model.ts against two for the devices
cutover. Both keepers stay put and the two newest rules move up:

  R11 contracts-implementation-authority -> R18
  R13 device-inventory-cutover           -> R17

R17/R18 follow the namespace's order-of-addition convention (R14 #1701 < R15
#1702 < R16 #1724): device-inventory-cutover landed in #1699 and
contracts-implementation-authority in #1701. #1656 took R19 for
selector-pipeline-ownership on the same reading.

The rule-map header in check.ts is renumbered and reordered back into numeric
order, and gains the R18 entry the contracts rule never had -- without it a
reader looking up an R18 violation finds nothing where they used to find the
wrong rule. deviceInventoryCutoverSummary() was also the only OK-line summary
not leading with its rule number, which is what made the number unreadable from
the success line in the first place.

Also corrects a normative ADR reference. ADR 0019's platform-package import
rules -- contracts-to-platform, sibling-platform, root/daemon, raw-process --
are R13's, as CONTEXT.md:420 already says. The R11 attribution predates
platform-package-policy (#1697, a day before #1699), when R11 was the only
package rule.

* chore(layering): retire the expired R11/R13 collision allowances

KNOWN_RULE_ID_COLLISIONS was opened for exactly the two collisions the previous
commit renames apart, and ruleIdCollisionFailures expires an allowance on
contact: once the collision is gone the entry fails as stale, because a list
still naming it would wave it back through if anyone reintroduced it.

Both entries are therefore deleted in the change that removes the collisions,
leaving the empty list that admits nothing. The namespace is now one-to-one
across R2-R19.
2026-08-12 11:42:15 +02:00
Michał Pierzchała 7f5dbd2e50 chore: drive unused production exports to zero (#1743)
* chore: drive unused production exports to zero

`pnpm check:production-exports` has been failing on main with 21 findings.
Each was investigated rather than blanket-suppressed; they split three ways.

Genuinely dead, deleted:
- `androidDeviceForSerial` (android/adb.ts) had zero references anywhere,
  tests included.
- `streamAndroidLogcatWithAdb` (android/logcat.ts) had no production consumer
  and only a guard-clause test; its `captureAndroidLogcatWithAdb` sibling is
  the published SDK surface. Removed with its options type and test.

Test-only aliases over live siblings, collapsed:
- app-log-resource-store re-exposed four bound store methods; production used
  only `resolvePath`, tests used the other three. The sibling
  screen-recording-resource-store exports just the store, so this now matches:
  one export, all consumers call `appLogResourceStore.x`.
- device-claims re-exported `canonicalLocalDeviceKey` for a single test, while
  production imports it from device-claim-paths directly. Dropped the
  re-export and pointed the test at the canonical module.

Real consumers the analysis cannot see, exempted with the reason:
- The nine remaining `src/cli/commands/*Command` handlers are reached only
  through `dedicatedCliCommandHandlerLoaders`, the dynamic import() table in
  router.ts. `deviceCommand` already carried an inline suppression for exactly
  this; replaced it with one config entry naming the table that enumerates all
  ten, matching the existing daemon route-handler entry.
- `resolveVitestMaxWorkers` (vitest.config.ts), `DEVICE_CLAIM_IN_USE_SAMPLE`
  (bench sample producers) and the capture-kit `createAppLogLiveHandle` facade
  export joined the existing entries that already record their exact shape.
- `**/*.fixtures.ts` is now an ignorePattern: all 15 build doubles for
  co-located tests, several import `vi`, and none is imported by production
  source. Pattern-matching them as test infrastructure also keeps this class
  of finding from recurring.

Gate now reports zero. Unit suite, layering, fallow audit, MCP metadata,
build, bundle-owner and package checks all pass.

* chore: scope the fixture exemption to unused exports

Review feedback on #1743: `ignorePatterns` removes a file from every Fallow
mode and rule, but the false positive here is only production-unused-exports.
Moved *.fixtures.ts to an ignoreExports entry so fixtures stay inside health,
dead-code and cycle analysis. Kept `exports: ["*"]` rather than today's three
symbols because the property is per-file — no fixture module has a production
consumer — so a new fixture symbol should not reopen the finding.

check:production-exports still reports zero, and a full `fallow --summary`
returns identical totals (2 dead-code / 4 dupes / 123 health) with and without
the change, so nothing was newly surfaced or newly hidden.

* test(fallow): prove fixture policy scope
2026-08-12 11:17:33 +02:00