Commit Graph

222 Commits

Author SHA1 Message Date
Michał Pierzchała 56b72c5cf7 refactor(boundaries): put shared contracts below their consumers, gate the result (#1405)
* refactor(boundaries): move shared contracts below their consumers

Acts on the depgraph findings: type-only edges are invisible to R5, so
vocabulary that everything depends on had drifted above the zones that use it.

- contracts/: the four platform-plugin facet tags (LogBackend,
  RecordingBackendTag, PerfMetricsSamplerTag, PlatformGatedProviderResolverKey)
  now live beside the plugin contract itself, which also moves out of core/;
  NetworkEntry moves next to the command surface that renders it; and the
  click-button, recording-export-quality, interactor-types and
  runner-lease-context vocabularies move down out of core/.
- (root) drops from 29 files to 13: the internal *-contract/output/annotation
  modules move into contracts/, kernel/ (daemon-error, observability-redaction
  beside kernel/redaction), core/ (batch-policy, an ADR 0008 projection),
  commands/ (cli-command-aliases) and remote/ (upload-progress, upload-stream).
  What remains is entrypoints and the composition roots that R2 requires to
  sit outside the spine.
- utils/ joins the ranked spine at rank 1 after its only two upward files move
  to the zones they were reaching for (cli/resolve-cli-options,
  cli-schema/cli-config), putting ~336 value edges under the gate.
- Internal imports that routed types through the client-types re-export hub now
  name their real source.

Type-only spine inversions drop from 61 to 35; the remainder is two clusters
(client/client-types.ts and the ADR 0003 daemon facet). No behaviour change:
4470 unit tests and the layering gate pass.

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

* style: merge the duplicate contract imports the tag moves created

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

* refactor(imports): name the declaring module, share find's argument rules

Two follow-ups from re-measuring the graph after the boundary moves.

1. 89 type imports across 79 files routed through a re-export hub in another
   zone: `CliFlags` reached through commands/cli-grammar/flag-types.ts (52) when
   it is declared in contracts/cli-flags.ts, the replay suite result types
   reached through daemon/types.ts when they are declared in contracts/replay.ts,
   the doctor types through a daemon handler module, and so on. Each hop invented
   a cross-zone edge the architecture never asked for — including every apparent
   replay -> daemon and utils -> commands dependency. They now name the module
   that declares them. Within-zone hops are left alone; those are a local style
   choice, not a boundary claim.

2. `find`'s three positional/flag checks existed in both daemon entry points with
   hand-repeated messages, and the copy in dispatchFindReadOnlyViaRuntime was
   unreachable — its only caller validates first. Both now call checkFindArgs in
   selectors/find.ts, beside parseFindArgs and isReadOnlyFindAction, for the
   reason that module's own comment already gives: so the two paths cannot
   disagree. The refusal is returned rather than thrown, because the two
   mechanisms are not observationally identical in the session event log.

Type-only spine inversions: 61 -> 35. 4470 unit tests and every gate pass.

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

* feat(layering): ratchet type-only spine inversions (R6)

R5 ignores type-only edges by design — they cost nothing at runtime and do not
affect cold start — so nothing was watching the direction they point. Ranking
them the same way found 61 inversions, including contracts/ and utils/ declared
in terms of rank-4 zones. 26 are fixed by the preceding commits; R6 pins the
rest per zone pair so they can only shrink, and a new pair fails outright rather
than being added to the baseline.

The two remaining clusters each need their own change, and the baseline says so:
the per-command Options/Result vocabulary declared inside the public Node-client
surface, and the ADR 0003 daemon facet shape that core's descriptor registry
composes.

Both ratchet directions are covered: growth fails, and shrinking without
lowering the number fails too, so the baseline cannot quietly stop describing
the tree.

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

* docs: record the import-graph findings behind this refactor

A dated snapshot, not a normative document: when it disagrees with
scripts/layering/, the gate wins. The graph tool that produced it lives on the
claude/depgraph-viewer branch, deliberately out of this change.

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

* refactor(selectors): state the shared selector argument rules once

R2 (commands-floor) forbids the daemon from importing commands/, and that is the
right call: commands/ is the client-side surface — its only consumers are cli/,
cli-schema/, mcp/, client/ and the composition roots — while the daemon is the
executor on the other side of the wire. ADR 0008 protects exactly that seam.
Relaxing R2 would let the executor depend on a client projection and pull CLI
grammar and output formatting into the daemon's bundle.

But the rule does force duplication: the daemon must validate independently
because it accepts requests from any client, so 10 refusal messages existed in
both zones. The only place a shared rule can live is below both, and selectors/
already held the parsers (splitIsSelectorArgs, splitSelectorFromArgs,
isSupportedPredicate) and even the `is` predicate message — just not the checks
that use them.

Three drifts had already appeared in the `is` predicate rule alone:

- commands/interaction/selectors.ts re-implemented the predicate list as an
  inlined seven-way `!==` chain while importing the message and hint from
  selectors/predicates.ts, so adding a predicate to the shared list would not
  have reached the CLI grammar.
- That inlined chain compared the raw token, so the CLI rejected `is TEXT ...`
  while the daemon it hands the command to accepts it. The CLI now matches the
  executor; this is an intentional alignment, not an accident.
- isCommand raised the same refusal without IS_PREDICATE_USAGE_HINT, so whether
  an agent got recovery guidance depended on which layer noticed first — the
  failure mode ADR 0010's audit calls out.

checkIsPredicate, checkIsArgs, checkGetFormat, checkElementTargetArgs and
checkWaitText now hold those rules, each beside the parser it wraps, and report
a refusal rather than choosing how to raise it: the daemon returns a response,
the command surface throws. Those mechanisms are not interchangeable — they
write different session events — so the shared check stays out of that decision.

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

* feat(daemon): give ADR 0014's ref frame one transition, pin SessionState owners

`SessionStore.get()` returns the live record out of a private Map and `set()`
re-puts the same reference, so every `session.<field> = …` in the daemon is a
durable write to store-owned state: 57 of them across 17 files, against 26
`set()` calls that are therefore ceremonial. Nothing at the store boundary can
check what those writes are supposed to keep true.

Measuring which module writes which field showed the problem is narrower than
the raw count suggests — 16 of 27 fields already have exactly one writer. The
sharp case is ADR 0014's ref frame: `refFrameState`, `refFrameScope`,
`refFrameTree` and `refFrameGeneration` must move together or the frame is
incoherent (an `active` state with a stale tree resolves refs against a
namespace nobody authorized), yet complete issuance wrote them in ref-frame.ts
and partial issuance wrote the same four in session-snapshot.ts. ref-frame.ts's
own header claims to be "the single owner of the frame's transitions", and
session-snapshot.ts documented itself as the exception. Both forms now go
through `activateRefFrame`; they differ only in scope.

`recordSession` deliberately moves alone in two paths (recording without arming
a publication), so the save-script cluster gets no invented abstraction — it
gets ownership instead. R7 records every field's owner and stops the set from
growing quietly: a new SessionState field must declare one, a foreign write
fails naming the owner to call, and an owner that stops writing must be removed
so the table cannot drift into fiction. Field names are read out of the
`SessionState` declaration, so a daemon module with an unrelated local named
`session` — a provider or runner session — cannot trip it.

4475 unit tests and every gate pass.

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

* docs: record the reference semantics and refresh the findings

SessionStore.get/set now document that the record is handed out live, since that
is the fact behind R7. The findings snapshot picks up the resolved R2 question,
the ref-frame consolidation and the two new gate scopes.

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

* refactor(boundaries): rank every satellite zone, extract the provider port

Second-order effect of the earlier rounds. With `utils` on the spine and
`(root)` emptied of shared contracts, the eleven zones that were unranked
"because ranking them would invent an order the architecture had not committed
to" turned out to have a consistent rank already — the order was there,
unasserted. Solving the constraint system showed one blocker: `utils/remote-config.ts`
projected a remote-config profile into `CliFlags` while reaching up into
`remote/`, and its only three consumers were in `cli/`. It moves there as
`cli/remote-config-flags.ts`, and every satellite zone joins the spine.

Ranked coverage goes from 730/895 files to 882/895. Only `(root)` stays out, and
now for one stated reason: R2 forbids `daemon/` from importing `commands/`, so
the files that wire them compose the spine from above.

Ranking them exposed 22 type-only inversions R6 had never been able to see, and
they were concentrated rather than scattered:

- The device-provider port. `providers/` and `cloud-webdriver/` implement what
  the daemon calls, so both sides name `DeviceLease`, `LeaseLifecycleProvider`,
  `LeaseLifecycleContext` and `DeviceInventoryProvider` — now declared in
  contracts/device-provider.ts, below both. The adapters also imported the
  daemon's NARROWED `DaemonRequest` while only ever reading `req.flags`; they now
  name the public one from kernel/contracts.
- `MetroPrepareKind` and the remote-config profile field groups move to
  contracts/ for the same reason: the command surface validates them and
  contracts/cli-flags.ts is composed from them.

Two clusters remain, ratcheted with their reasons in TYPE_INVERSION_BASELINE:
the client-types vocabulary, and `SessionAction`, which needs `CommandFlags` and
`DaemonBatchStep` to move with it.

Also fixes two things CI caught: the eight type re-exports my earlier import
redirection orphaned (none published through any src/sdk/* entrypoint, so no
public surface changes) and `isSupportedPredicate`, now module-private since
`checkIsPredicate` is the admission API. `fallow-baselines/health.json` is keyed
by path, so the moved cli-config entry moves with the file rather than being
regenerated.

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

* fix(selectors): use the admitted predicate, not the raw option

Review finding. `isCommand` called `checkIsPredicate` and then kept reading
`options.predicate` for the capture policy, the `exists` branch,
`evaluateIsPredicate`, the failure message and the returned result. Admission
normalizes case, so an upper-case predicate was let past the gate and then
evaluated against lower-case branches: `EXISTS` skipped its own branch and fell
through to the generic path, and the result echoed the raw token. I widened
admission at that surface without threading the normalized value through it —
the CLI-grammar surface in the same change does use the admitted value.

Every decision after admission now reads it.

Two tests, both verified to fail without the fix:

- a production-route regression driving `device.selectors.is` with
  `EXISTS`/`TEXT`, plus one pinning that an unknown predicate is still refused
  WITH the ADR 0010 usage hint;
- a surface parity gate (selectors/__tests__/is-argument-surface-parity.test.ts)
  in the repo's existing parity style, asserting the daemon and CLI-grammar
  surfaces reach the same verdict and hand the same normalized predicate
  downstream across an input table. A helper-only test cannot catch a surface
  that admits correctly and then discards the result, which is what happened
  here.

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

* docs: name the pre-push gate, and the formatter's path allowlist

Both misses in this PR's review were process, not judgement, and the docs
pointed the wrong way for both.

AGENTS.md said "prefer the aggregate package.json scripts" without naming which
aggregate, and CONTRIBUTING listed `pnpm test` and the targeted checks but never
`pnpm check`. `check:tooling` looks like the gate and is a subset of it: it stops
before the Fallow audit, so the dead exports this PR introduced passed a clean
`check:tooling` and failed CI. Both files now name `pnpm check`, say what it
covers, and say what it cannot (the device matrix).

The same gap produced a second mistake twice: `oxfmt <path>` reformats whatever
you point it at, while the repo's `format` script is an allowlist that excludes
`scripts/` and every `.md`. One run reformatted 50 unrelated script files into a
commit; the next nearly did it to AGENTS.md. AGENTS.md now says to run
`pnpm format`, never `oxfmt <path>`.

It also records the rule that cost a CI cycle: Fallow's baselines are keyed by
path, so a renamed file needs its baseline entry moved, not the baselines
regenerated.

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

* revert: undo stray formatter output across docs and scripts

Three separate `oxfmt <path>` runs in this branch reformatted files the repo's
`format` script deliberately excludes: 55 files under scripts/maestro-conformance
plus scripts/perf, sync-mcp-metadata and the slow-test reporter, and 12 markdown
files including six ADRs and docs/agents/. All of it was whitespace, quote style
and markdown table padding — no content — but it inflated the diff a reviewer has
to read and would have rewritten prose ownership across files this change has no
business touching.

All 70 are back to their origin/main content, so the diff outside src/ is now
exactly this change's scope: three docs, scripts/layering, the Fallow baseline,
and five provider integration tests.

The rule this violated is now in AGENTS.md: run `pnpm format`, never
`oxfmt <path>`.

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

* style: reformat two provider tests with the repo's pinned oxfmt

`pnpm format:check` failed in CI on the two files whose imports I merged by hand.
The repo pins oxfmt 0.42.0 as a devDependency and both `format` scripts invoke
`./node_modules/oxfmt/bin/oxfmt`; I had reformatted with `npx oxfmt`, which
resolved 0.60.0, and the two versions disagree about wrapping a 100-column import.

This is the rule AGENTS.md already states — run `pnpm format`, never oxfmt
directly — so there is nothing to add to the docs, only to do.

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

* fix(ci): install deps for the layering guard, and gate the zero-dep contract

The Layering Guard job failed with ERR_MODULE_NOT_FOUND on `oxc-parser`. The job
ran with `install-deps: false` — no `pnpm install`, so no `node_modules` — and R7
had started parsing the daemon with oxc-parser instead of matching assignment
operators with a regex. `pnpm check:layering` passed on every local run, because
locally `node_modules` is always there.

The job now installs dependencies. The alternative was to put R7 back on a regex,
which cannot see `??=` or a computed `session[key] =` write, so it would trade a
correct rule for a fast job.

That leaves the interesting part: the zero-dep contract is real for the jobs that
keep it, and it is invisible to every local run, which is the worst combination a
constraint can have. R8 makes it checkable. It reads the zero-dep job list out of
`.github/workflows/` rather than restating it — declaring a job zero-dep is what
puts it under the rule — walks each job's entry scripts and their whole
relative-import closure, and requires every specifier to be a Node builtin or
another repo file. A zero-dep job whose entry scripts the scan cannot identify
fails too, so the rule cannot be escaped by changing how the job invokes them.

Specifiers come from oxc-parser's module record, not a line scan. The closures
include `--test` files, and a test about imports naturally embeds import syntax in
a fixture string; the line scanner reported two such phantom violations in
model.test.ts before the switch, which is how a gate stops being trusted.

Verified by re-running the real gate against three injected regressions: the
layering job back on `install-deps: false` (reproduces the exact CI failure,
pointing at session-state.ts:24), a package import added to the still-zero-dep
affected-selector closure, and a zero-dep job whose run step names no script.

Also corrects the CONTEXT.md spine paragraph, which still described the satellite
zones as deliberately unranked after they had all joined the ranked spine.

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

* fix(layering): make R7 exhaustive, and follow session records through aliases

Review finding: `SESSION_STATE_FIELD_OWNERS` covered 27 of `SessionState`'s 42
fields and nothing asserted parity, so a new field could be added and pass the
gate by being invisible to it. R7's advertised claim — "every SessionState write
is inside its declared owner" — was broader than what it checked.

Investigating that turned up a second, larger gap the finding did not name: the
scan only recognized a binding literally named `session`. The daemon names these
records by role, so `nextSession`, `provisionalSession`, `completedSession`,
`preRunSession` and `preEntrySession` were all invisible — and three of those
writes were genuine violations R7 existed to catch:

  src/daemon/snapshot-runtime.ts:256  nextSession.snapshotScopeSource
  src/daemon/snapshot-runtime.ts:265  nextSession.snapshotGeneration
  src/daemon/handlers/session-replay-runtime.ts:707
                                      preEntrySession.pendingRecordAndHeal

The first two are the #1076 versioned-ref invariant: the generation advances
exactly when the stored tree is replaced. That rule lived in `setSessionSnapshot`
and had acquired a second statement of itself in snapshot-runtime.ts, whose own
comment admitted the bypass. It now goes through `setSnapshotLineage` in the
owning module. The third clears a watermark stamped by session-replay-resume.ts;
`clearPendingRecordAndHealWatermark` puts the clear beside the stamp.

Gate changes:
- Binding detection accepts aliases, paired with the existing declared-field
  filter so an unrelated `…Session` local only registers if it also writes a
  field SessionState owns — where the remedy is the same anyway.
- `fieldClassificationDrift` asserts parity in all three directions:
  unclassified, in-both, and naming a field SessionState no longer declares.
- `STORE_OWNED_SESSION_STATE_FIELDS` classifies the 11 fields the store
  establishes at construction. It is a positive claim, so a direct write to one
  fails and names both remedies.
- Four fields the widened scan made visible (`lease`, `deviceClaim`, `appName`,
  `saveScriptComplete`) got real owners.

`nextSnapshotGeneration` is now module-private: replacing its only external call
site orphaned the export, which `pnpm check` caught via Fallow.

Verified against three injected regressions: a new SessionState field with no
direct write (the reviewer's exact scenario), a foreign write through an alias
binding, and a direct write to a store-established field. All three rejected.
`pnpm check` green, 4486 unit tests.

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

* docs(daemon): correct the snapshot-lineage claim, and pin the real contract

Device verification of the snapshot-lineage route found that a ref pinned before
a `diff` keeps resolving with no pinned-ref warning. That is the designed ADR
0014 behaviour, not a regression — the comment describing it was wrong, and I
propagated it.

`main`'s comment in snapshot-runtime.ts said a diff "leaves client refs pinned to
the previous generation, which is exactly what the pinned warning diagnoses". The
counter and the authorization epoch are different clocks:

  - `diff` passes `issuesRefsToClient: false`, so it never reactivates the frame;
  - `resolveRefStalenessWarning` compares a pin against the frame EPOCH, not the
    observation counter, and its own comment says why — a capture that bumped the
    counter must not make a valid pin from the issuing frame look stale.

So advancing the counter is not the same as invalidating client refs, and the
observable the comment promised does not exist. I carried the sentence into
`setSnapshotLineage`'s doc when the transition moved, and then into a hardware
verification request, which cost a reviewer a device run against a false claim.

`setSnapshotLineage` itself is unchanged and was a pure move: same expressions,
same inputs as the inline assignments it replaced, so this route behaves exactly
as it does on main.

A comment that contradicts the code should be an assertion instead, so the
contract is now pinned in session-snapshot.test.ts: the diff advances the counter,
preserves the epoch, leaves the pre-diff pin resolving without a warning, and
still warns for a pin from a different frame. Verified to fail when the epoch
comparison is swapped for the counter. A second test covers the keep-current
branch, which had no coverage.

`pnpm check` green, 4488 unit tests.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 08:08:34 +02:00
Michał Pierzchała 287cc18c29 fix(gestures): fail pre-removal gesture forms at .ad parse time (#1216) (#1393)
* fix(gestures): fail pre-removal gesture forms at .ad parse time (#1216)

#1315 removed the timed forms of `swipe`, `gesture fling`, and `gesture swipe`
and `gesture rotate`'s `velocity`, but shipped without the migration guide, the
repository sweep, or the parse-time error that issue #1216's own checklist
gates a removal on. The sweep finds what that left behind: both
`06-swipe-gestures.ad` integration fixtures still carried the 5-argument swipe
and would fail at replay, two tests still asserted the removed shapes, and two
branches still read the retired positional.

Argument arity for every public gesture syntax now lives in one table keyed off
the canonical `GESTURE_KINDS`, so a new kind cannot skip it and a form removed
from the CLI is removed from `.ad` in the same edit. Both callers read it: the
CLI argv parse, and a new `.ad` preflight. A stale script now fails when it is
parsed — before the replay executes any device action — naming the line and
computing its rewrite, instead of running up to that step and failing as a
repairable divergence. The preflight checks arity only: `${VAR}` tokens resolve
after planning, and interpolation never splits a token, so the count is
decidable while the values are not.

Deleting the dead duration read in `readSwipeGeometry` would have left
`replay export` emitting no duration, handing Maestro's 400ms default to a
gesture the script runs at 100ms, so the export now states `duration: 100`.

`.ad` positional gesture parsing is NOT removed. Its only remaining callers are
the CLI argv parse and the `.ad` line parse, both the current public syntax
rather than a bridge to an older one, so there is nothing to migrate off. ADR
0013 records that and drops the "compatibility" framing that made it read as
debt.

Both migrated fixtures verified on real devices with the repo's own CLI: iOS
simulator 34.9s, Android emulator 45.9s.

* fix(gestures): reject removed swipe input at the Node/MCP boundary

Review findings on d88c6ed8.

P1: `interactionDaemonWriters.swipe` hand-projects five fields, so a JavaScript
caller's `durationMs` was dropped before the daemon's `readSwipeInput` could
reject it and a default-duration fling ran instead — the exact silent
reinterpretation the guide promises does not happen. `gesture` was already safe
because its writer runs `readGestureInput` -> `readGesturePayload`, which
rejects the removed keys; `swipe` was the one surface with no reader of its own.
The rejection now lives in contracts and is shared by the client writer and the
daemon handler, so there is one rule and one message. The SDK regression covers
all four removed keys and asserts the transport is never reached; reverting the
writer call fails it on `swipe durationMs`.

P2: the preflight's retired-slot test required a numeric token, so
`swipe 197 650 197 300 ${DURATION}` fell back to bare usage text. An unresolved
`${VAR}` now counts as the retired slot and is carried into the pan rewrite,
while a stray flag or word stays a plain usage error.

P2: the removal shipped in 0.20.0, not 0.21 — removal commit 6d99914f4 is
contained in tag v0.20.0. The guide said 0.21 because the CHANGELOG still files
it under `Unreleased`; the tag is the truth (headings lag several releases
repo-wide, so that is pre-existing and left alone). The `.ad` grep recipe now
matches variable-backed durations too.

* docs(gestures): make the migration sweep and MCP claim accurate

Re-review findings on 328bad8e (both migration-guide accuracy).

The saved-script sweep matched only the five-argument `swipe` form; it missed
timed `gesture fling`, timed `gesture swipe`, and `gesture rotate ... velocity`,
so the repository-cleanliness step was incomplete. Provide one grep per retired
form (number-or-`${VAR}` token), each verified to flag the removed shape and
skip the valid one.

The MCP section claimed the structured rejection carries the CLI's concrete
replacement command. It does not: `readGesturePayload` and
`assertNoRemovedSwipeInput` return a message that names the removed key and the
replacement command (e.g. `gesture fling does not accept durationMs; use gesture
pan for timed movement`) but not the fully-substituted coordinate rewrite,
because the structured request carries no positional string to rewrite. Describe
what the structured path actually returns.

* docs(gestures): make the .ad sweep parser-aligned

Re-review finding on 4fd5d39: the documented sweep required literal single
spaces and unquoted numeric tokens, but the `.ad` tokenizer separates on any
whitespace (`/\s/`, so tabs too) and accepts double-quoted tokens, so
tab-separated or quoted-duration stale lines the parser rejects were missed.

Rewrite the patterns to follow the tokenizer: `[[:space:]]+` between tokens and
a numeric slot that accepts a bare or double-quoted number (optionally signed)
or `${VAR}`. Requiring a digit in the numeric slot keeps a trailing flag like
`--count` from being read as the retired positional. Verified against a fixture
of tab/space/quoted/quoted-var/negative encodings that the four greps flag
exactly the lines `parseReplayScriptDetailed` rejects and none it accepts, and
that the live repo sweeps clean.

Also state plainly what a regex cannot promise: the parser is the authoritative
gate — every retired form is rejected at parse time before any device action, so
running the suite finds every stale line by construction and a missed grep can
never reach execution. The grep stays a bulk pre-flight, and the "every affected
line" claim is scoped to that.
2026-07-26 11:41:02 +02:00
Michał Pierzchała 256887f194 feat(apple): injectable Apple runner transport seam for provider interactors (#1389)
* feat(apple): injectable runner transport seam for provider interactors (#1297)

createAppleInteractor now accepts an optional AppleRunnerProvider (or bare
command executor). When injected, every runner-command method runs inside
withAppleRunnerProvider scope, so the shared selector/tap/fill/scroll/
snapshot stack rides the provider transport instead of local XCTest —
mirroring createAndroidInteractor's AndroidAdbProvider parameter.

Methods backed by local Apple tooling (simctl/devicectl: open, openDevice,
close, screenshot, clipboard, setSetting) fail fast with
UNSUPPORTED_OPERATION in provider mode instead of silently running local
tooling against a remote device; provider sessions compose their own
implementations on top.

Local behavior is unchanged: without the new parameter the factory returns
the same interactor as before, and daemon-owned sessions keep resolving the
local XCTest runtime.

* fix(daemon): request-boundary provider runner scope + per-request interactor context

Review findings on #1389 (P1/P2):

P1 — daemon routes that issue Apple runner commands outside interactor
methods (keyboard, native alert, point read, iOS sequence chunks) escaped
to the local XCTest runtime for provider devices. ProviderDeviceRuntime
now exposes getAppleRunnerProvider; createProviderDeviceRuntimeRequestProviders
composes it into an appleRunnerProvider request resolver and the daemon
runtime wires it, so the existing request-boundary scope covers those
routes with the provider transport.

P2 — per-request RunnerContext was discarded for provider devices:
getInteractor threads it through getProviderDeviceInteractor into
ProviderDeviceRuntime.getInteractor, so runtimes composing the shared
Apple interactor keep requestId (cancellation/accounting), appBundleId,
and log paths per request.

Lease-route commands (lease_allocate/heartbeat/release, artifacts) now
skip sessionless provider-device resolution: they manage lease lifecycle,
not a device session, and resolving a default device there spuriously
triggered local device discovery before any lease existed.

Integration coverage: keyboardDismiss reaches the provider transport via
the request scope; every runner call in a request carries that request's
id.

* test(daemon): assert direct-route runner calls keep the request id through the provider scope

Re-review follow-up on #1389: the keyboard-dismiss provider-scope test now
sends a requestId and asserts the recorded runner call carries it, proving
the request-boundary appleRunnerProvider scope preserves per-request
context for direct daemon routes (not just deviceId matching).

* fix(daemon): revert-sensitive transport tests, route-derived lease skip, guarded provider-scope resolve

Review round 3 on #1389:

1. The integration acceptance test passed with the interactor transport
   param removed — the request-boundary scope was routing for it. Tests now
   run in two worlds: the shared-stack and per-request-id tests use a world
   WITHOUT getAppleRunnerProvider (the interactor param is the only seam;
   verified failing when the param is dropped), while the direct-route test
   keeps the request scope it pins.

2. skipSessionlessProviderDevice for lease-route commands is now derived
   from daemon.route === 'lease' in shouldSkipSessionlessProviderDevice
   instead of hand-spread across four descriptors, with a registry-driven
   invariant test enumerating the route.

3. resolveScopedProviderDevice catches resolveTargetDevice failures and
   returns undefined: provider-scope plumbing failing to find a device
   means 'no provider scope', never a failed request.

Also collapses the duplicated provider-scope Proxy from android.ts and
apple/interactor.ts into core/interactor-scope.ts, and restates the
transport param's role (local-tooling partition + out-of-daemon scoping)
in its doc comment.

* fix(core): move the provider-scope proxy below the ranked spine

Layering Guard (R5 zero-back-edges) rejected platforms/apple/interactor.ts
value-importing core/interactor-scope.ts: platforms (rank 1) may not import
core (rank 2). The helper needs nothing from core — generic withMethodScope
in utils (unranked, already imported by both zones) replaces it.

The prior local layering pass was a false green: the guard enumerates
tracked files and the new helper was untracked when the gate ran.
2026-07-25 15:22:42 +02:00
Michał Pierzchała 75b5bc5d6d feat: add first-class Vega VVD TV support (#1396)
* feat: add first-class Vega OS TV support

* fix: scope Vega support to VVD

* fix: tighten Vega platform boundaries
2026-07-25 14:02:25 +02:00
Michał Pierzchała 877e68fe30 fix(cli): compact stale device status (#1388)
* fix(cli): compact stale device status

* fix(cli): quote stale status selectors
2026-07-25 11:44:22 +02:00
Michał Pierzchała 5507a08b9c feat: parameterize sensitive recorded inputs (#1369)
* feat: parameterize recorded inputs

* fix: harden parameterized replay recording

* fix: sanitize parameterized fill echoes

* fix: scrub embedded parameterized fill echoes

* fix: make recorded fill scrubbing idempotent

* fix: replay parameterized coordinate fills

* test: align parameterized publication landmark
2026-07-24 20:37:29 +02:00
Michał Pierzchała 5a50cfb892 fix(daemon): keep an active replay session's daemon alive over the CLI path (#1390)
* fix(daemon): keep an active replay session's daemon alive over the CLI path

A `replay <script>.ad` with no terminal `close` reports its session as
still active per ADR 0016's consumption contract, but the real CLI
client tears down the daemon that ran it (and its owned ephemeral
state dir) regardless — the request's own success response gets
overwritten seconds later by an empty `session list`. This happens
whenever the client started the daemon itself, independent of whether
the state dir was randomly generated or passed explicitly via
--state-dir/AGENT_DEVICE_STATE_DIR, matching #1384's live repro.

Add `sessionActive` to `ReplayCommandResult`, computed from whether the
session survives in the daemon's own store (never by re-parsing the
script), and gate the client's one-shot teardown on it — mirroring the
existing ADR-0012 repair-divergence keep-alive. A kept-alive owned
daemon now also attaches a --state-dir address hint to the response so
the caller can reach it. `test` is unaffected: its own per-file runner
already closes each session before the suite summary is built.

Fixes #1384

* fix(daemon): fix CI formatting, address #1390 review feedback

- oxfmt --check flagged the new test file; reformatted (CI fix).
- Address hint now names --session <name> too, using the response's
  session verbatim (already the fully-qualified cwd-scoped store key) —
  a bare --session default only resolves by coincidence from the same
  cwd, per resolveEffectiveSessionName's explicit-flag bypass.
- Add a test closing the loop: a follow-up sendToDaemon using the
  hinted --state-dir/--session reaches the same kept-alive daemon
  without spawning a new one.
- Pin that a completed (non-diverging) --save-script repair also keeps
  its daemon alive via the same guard, since its terminal source close
  is always skipped (ADR 0012 Fix 3) — document the resulting deferred
  heal-commit timing in ADR 0012.

* test(daemon): reduce complexity of new active-session tests for CI gate

Fallow's audit gate (new-only findings) flagged the two new active-
session tests for exceeding the CRAP threshold. Extract the shared
fixture wiring into replayLeavingSessionActive/parseAddressHint
helpers (also cutting duplication between the two tests), and drop
repeated optional chaining on response.data in favor of a single
narrowing assert.ok(data) — same assertions, lower branch count.

* fix(daemon): add sessionActive to MCP schema, real-producer tests, ADR fix

Addresses the second review pass on #1390:

- MCP replay output schema (src/mcp/command-output-schemas.ts) omitted
  the new required sessionActive field entirely; add it.
- ADR 0016 still claimed "the absence of close changes ... nor the
  success response shape", contradicting the new required field this
  PR adds. Amend it to document the sessionActive contract and why
  the real CLI/IPC client needs it (issue #1384).
- All prior lifecycle tests exercised sessionActive only through a
  fake HTTP response in the client-layer tests, so deleting either
  real producer line (session-replay-runtime.ts, session-replay-
  maestro-response.ts) would not have failed anything. Add tests
  against the real runReplayScriptFile producer (native .ad close-less
  -> true, terminal close -> false, Maestro close-less -> true) and
  strengthen the provider-scenario (real daemon route) test with the
  same assertion. Verified each new test fails when its corresponding
  producer line is reverted, then restored.

Live-validated the fix on real backends (booted iOS 16 simulator and
a running Android Pixel 9 Pro XL emulator), replaying issue #1384's
exact repro end to end: the owning daemon and its session both survive
a close-less replay and remain fully addressable via the hinted
--state-dir/--session on both platforms. That validation surfaced a
separate, pre-existing bug -- `session list` (no explicit --session)
omits cwd-scoped sessions opened via a replay's internal `open`
dispatch, because session-open.ts's resolveImplicitSessionScope(req)
sees a different req than the top-level replay request and leaves
session.sessionScope unset -- filed as #1394, out of scope here since
it is a sessionScope-propagation gap unrelated to the client-side
teardown this PR fixes; the session itself is never actually lost.

* fix(daemon): hint --session at explicit state dirs too, shell-quote the hint

Addresses the third review pass on #1390 (P2 x2):

- withActiveSessionAddressHint (renamed from ...IfOwned) no longer
  suppresses the active-session hint entirely for an explicit
  --state-dir/AGENT_DEVICE_STATE_DIR caller. The session name is
  cwd-qualified and, per #1394, `session list` can't rediscover it
  either, so --session is now hinted regardless of ownedStateDir;
  --state-dir is only included when the state dir is the client's own
  randomly-generated one the caller has no other way to learn.
- attachActiveSessionAddressHint now shell-quotes (shellQuoteIfNeeded,
  the same helper session-recovery-hints.ts/request-lock-policy.ts
  already use) both the state dir and session name, so the hint stays
  literally copy-pasteable even if either contains spaces or shell
  metacharacters. Added tests pinning both the unsafe-value quoting
  and that quoting was actually exercised (not just coincidentally
  unchanged) -- verified they fail against a raw-interpolation
  reversion, then restored the fix.
2026-07-24 20:09:35 +02:00
Michał Pierzchała 9b610fbd1e feat(replay): recorded landmark identity for wait, is coverage — read-only step identity (#1349) (#1381)
* refactor(replay): extract shared target-evidence tree helpers into src/replay

Move buildIndexMap/buildAncestryChain/filterIdentitySet out of the daemon's
session-target-evidence into the shared replay zone so the commands runtime
(wait's polling loop, #1349) can consume them without importing the daemon;
press-retarget drops its private buildIndexMap duplicate.

* feat(replay): recorded landmark identity verification for wait, get-pattern coverage for is (#1349)

- New CommandDescriptor trait targetIdentityVerification pins the
  evidence-carrying command set and routes wait to a post-resolution
  phase so an annotated wait never enters the generic pre-dispatch
  verification (an absent landmark is its expected starting condition).
- wait <selector> records landmark-mode target-v1 evidence (existence
  self-check; identity-empty matches record no annotation) and, on
  replay, keeps polling until a selector match carries the recorded
  identity; a deadline with only impostor matches fails closed as an
  identity-mismatch REPLAY_DIVERGENCE, a recorded-unverifiable
  annotation refuses before polling, and a plain timeout stays an
  action-failure divergence.
- is (except exists) joins the get pattern: evidence at record time,
  generic pre-dispatch verification, and the post-resolution guard
  threaded through dispatch; direct-iOS fast paths for wait/is are
  gated during recording and guarded replays.
- Read-only find stays intentionally unannotated (fuzzy-locator
  resolution has no selector-chain identity token), proven by test.

* feat(publication): destination guard requires verified recorded landmark identity (#1349)

A qualifying ADR 0016 guard is now a selector wait whose target-v1
annotation is verified; identity-less or unverifiable guards are refused
with a recovery hint. Adds the reshuffled-screen false-pass regression:
record -> publish -> replay against a same-label/different-ancestry tree
diverges as identity-mismatch (matchCount >= 1 proving the selector
alone would have false-passed).

* refactor(replay): dedupe post-dispatch identity-mismatch shaping, trim evidence-writer complexity

Shared buildPostDispatchIdentityMismatchResponse behind the guard and
wait-landmark conversions; extracted payload-ceiling helpers from
computeTargetEvidence; identity-refusal conversion split out of
resolveReplayStepResponse. Docs: ADR 0012 decision 3 amendment (#1349),
ADR 0016 guard strengthening, help workflow/save-script text.

* refactor(replay): make landmark evidence's record-time verification explicit, trim ADR-restating docs

The landmark-mode self-check was provably a tautology (the winner is a
member of its own identity set whenever the parent walk is intact), so a
membership scan defended only by a comment is replaced with the explicit
decision: broken walk fails closed, landmark is verified by construction,
action mode keeps decision 3's step-5 self-check. Doc comments that
re-argued the ADR amendment now state behavior and point to it.

* chore: untrack multitouch-helper build artifacts, ignore its build/dist dirs

Generated Android helper output swept into the earlier refactor commit by
accident; analogous snapshot-helper/ime-helper build dirs were already
ignored.

* fix(interaction): wait polls ride out content-unreadable captures (live-validated on Android)

Live ADR 0016 validation on a Pixel emulator showed a destination-guard
wait replayed immediately after a navigation press deterministically
dies: the first poll's capture lands mid-transition and the Android
helper's 'insufficient foreground app content' verdict threw out of the
polling loop. iOS already yields the same state as a sparse verdict
with no matches, so the loop kept polling there — this makes wait
semantics platform-consistent. A content-verdict capture failure
(isUnreadableCaptureContentError) now counts as a no-match poll for
selector and text waits; a wait whose screen never became readable
rethrows the last capture verdict at the deadline, so persistent
breakage keeps its diagnosis. Other capture failures still throw
immediately.

* fix(snapshot): narrow unreadable-capture classification to enumerated content verdicts

Android stamps androidSnapshotHelperFailureReason on mechanism failures
too (helper timeouts, adb failures, missing helper artifact — free-form
reason strings), so matching any string made waits poll those to their
deadline instead of failing immediately. The predicate now matches only
the enumerated content-recovery reasons, and
AndroidHelperContentRecoveryDecision derives its reason union from the
same list so a new content verdict cannot miss the predicate. Adds the
realistic wrapped mechanism-error regression the synthetic test missed.

* test(interaction): make the wait mechanism-failure regressions revert-sensitive

Assert exactly one capture attempt: the broad any-string classifier
would poll the repeated fixture error to the fake-clock deadline and
rethrow the same message, passing the message-only assertion. Verified
the mechanism test fails against the broadened classifier and passes
against the narrowed one.
2026-07-24 20:05:09 +02:00
jibin jose 968db8b26f fix(daemon): explicit abort message on uncommitted repair close (#1383)
* fix(daemon): explicit abort message on uncommitted repair close

Fixes #1380

* style: run pnpm format

* fix: loud abort for close --save-script on uncommitted repair

* fix: address static check failures from review
2026-07-24 12:54:52 +02:00
Michał Pierzchała c0fc822e80 chore: remove dead code and tune fallow's dead-code rules (#1363)
Evaluated knip (webpro-nl/knip) against the fallow setup already in the
repo, cleaned up everything it surfaced, then removed knip again: measured
head-to-head on the same tree, fallow is a strict superset once two
switches it already supports are flipped.

Dead code removed:
- `daemon/artifact-materialization.ts` (224 lines) had no production
  caller, only its own test. Removing it exposed that
  `downloadArtifactToTempDir` and the whole URL-fetch-with-redirects path
  in `artifact-download.ts` were reachable only through it — the live
  upload paths use the incoming-request helpers instead. That file goes
  348 -> 123 lines. `readZipEntries` then fell out of `artifact-archive.ts`.
- Dead test-helper exports: 12 unused re-exports and 6 needlessly-exported
  mocks in `session-test-harness.ts`, dead barrel entries in
  `__tests__/test-utils/index.ts`, plus `withMockedXcrun`, `matchesSchema`,
  `IOS_FRAME`, `IOS_TAB_FRAME`, `snapshotWithOffscreenContent`.
- `androidSnapshotHelperOutput` was duplicated byte-for-byte in
  `provider-scenarios/android-world.ts`; it now imports the shared copy.
- 8 unreferenced type aliases, and 17 redundant type re-export lines in
  `client/client-types.ts`. The published `.d.ts` is byte-identical before
  and after all 19 files: those types already reach consumers through
  `contracts/*` via `CommandResult<...>`, so this is not an API change.

Tooling:
- `.fallowrc.json` gains `includeEntryExports`,
  `ignoreExportsUsedInFile: {type, interface}` and `unused-types: warn`.
  That combination is what made the findings above visible; the previous
  config was quiet mainly because of its own suppression list.
- Dropped 2 now-obsolete `ignoreExports` suppressions, added 3 documented
  ones (published `sdk/*` surface, tool-config `default` exports, and the
  `AssertTrue<...>` totality guards that exist only to satisfy
  `noUnusedLocals`).

`unused-types` stays at `warn`: 72 pre-existing type re-export lines across
27 files remain, tracked separately. Every other fallow detector is at zero.
2026-07-22 17:16:02 +02:00
Michał Pierzchała c84b449ecc fix(android): stamp status-bar chrome during the walk instead of reconstructing it downstream (#1319) (#1359)
* test(settle): pin that an expanded quick-settings shade stays visible to --settle (#1319)

#1319 asked whether the systemui run-condemnation rule leaves `--settle` blind
to a fully expanded quick-settings shade, the way it blinded the replay
divergence in #1318. It does not, and this pins the reason.

The settle loop captures `interactiveOnly: true` (`stable-capture.ts`). That
walk drops the structural systemui window spine (`legacy_window_root`,
`notification_panel`, `qs_frame`, the quick-settings ComposeView chain) — and
those are exactly the nodes that merge the shade into ONE contiguous run in the
`--raw` / non-raw shape #1318 measured. Under settle's shape the same capture
arrives as five runs, only `split_shade_status_bar` carries a marker, and the
29 quick-settings nodes survive into both diff sides.

So settle and divergence do not read one tree differently, as #1318 framed it:
they consume different capture shapes, and settle already gets the outcome that
layer wants — shade content diffs, status-bar churn does not.

Separately, the quiet-detection loop digests the UNFILTERED capture
(`digestSnapshotNodes` in `stable-capture.ts`), so the shade would reset the
quiet window even in the hypothetical where chrome stripping had emptied the
diff. Both halves of the question come out clean.

No product change. The behavior is correct but rested on an untested structural
coincidence: retaining the systemui spine in the interactive walk would re-merge
the runs and make `--settle` report a full-cover shade as bare removals with no
added content and no hint. The test fails if that happens (verified by flipping
the helper to `interactiveOnly: false`).

Verified live on emulator-5554 (Pixel 9 Pro XL API 37, deskclock), both
directions #1319 asked about and both halves the test asserts:

  - shade OPENING mid-settle:  settled after 6917ms: +28 -25
    (added: brightness seekbar, Wi-Fi/Bluetooth/Mobile data/Quick Share/
    Modes/Wallet tiles)
  - shade CLOSING mid-settle:  settled after 6919ms: +25 -28  (mirror image)
  - the shade's own status bar ("Tue, Jul 21, Wifi signal full., T-Mobile") is
    absent from both settle diffs while `snapshot -i` of the identical screen
    still lists it — the run rule filters the churn rather than sitting inert.

The archived #1318 capture run through the real interactive-only walk reproduces
the live tree node-for-node (35 nodes, 29 kept, 6 stripped).

* docs(android): correct the chrome-classifier TODO — window-type keying is ruled out on device

Follow-up to the #1319 investigation, acting on review feedback that a
paragraph justifying fragile behavior means the code is wrong.

Two candidate fixes for the capture-shape-dependent chrome classification were
tested, and BOTH are dead. Recording that here so the next person does not
re-walk them:

1. The window-type approach this file's own TODO proposed. Measured against the
   live helper XML (emulator-5554, Pixel 9 Pro XL API 37): systemui reports
   `window-type=3` (TYPE_SYSTEM) both collapsed and expanded, `TYPE_STATUS_BAR`
   (2000) never appears, the helper stamps window metadata on window ROOTS only
   (1 of 169 nodes in an expanded-shade capture), and an expanded shade is ONE
   window hosting the status icons AND the quick-settings tiles. No window-level
   signal separates them. The TODO promised a fix the device data rules out.

2. Replacing run-condemnation with "condemn each marked node's subtree plus
   fully-condemned ancestors". Shape-independent as intended, and it keeps the
   tiles in both walks — but on the COLLAPSED status bar it leaks the unmarked
   chrome the walk re-parents next to the markers ("Battery 100 percent.", the
   notification-icon summary, neither carrying any resource-id). That is the
   ticking-clock regression #1319 explicitly warned against.

So run-condemnation is not a lazy workaround: it reconstructs identity the walk
already discarded when it drops the `status_bar*` wrappers and re-parents chrome
leaves next to content. That upstream information loss is the actual root cause,
and a provenance-preserving walk is the real fix — larger than this PR, and it
would let #1318's divergence fallback be revisited.

Also trims the #1319 test's doc comment from a long justification of the current
behavior down to the finding, the defect, and what the test holds in place.

* fix(android): stamp status-bar chrome during the walk instead of reconstructing it downstream

Chrome classification gave opposite answers about the same screen depending on
capture shape: an expanded quick-settings shade was 100% chrome under the
`--raw`/non-raw walk (#1318 — every tile condemned, needing a divergence-local
fallback) and ~17% chrome under the interactive-only walk (#1319). Two layers
were papering over one broken classifier.

Root cause: `shouldIncludeStructuralAndroidNode` drops the `status_bar*` /
`navigation_bar*` containers — the only nodes that identify the region — and
re-parents their leaves next to real content. Everything downstream was
reconstructing identity the walk had already discarded, and reconstruction is
what depended on shape.

Fix: record it while it still exists. `walkUiHierarchyNode` threads
`ancestorSystemChrome` exactly like the `ancestorHittable` it already carries,
and stamps `systemChrome` on the emitted node; `androidUiNodes` tracks the same
subtree for the streaming content-recovery pass. Classification is then per node
and intrinsic, so `--raw` and non-raw agree by construction.

This deletes what was compensating for the loss:

- the 18 hand-picked marker leaf ids and their justification comment (what
  enumerates them is "descendant of a status/nav-bar container" — now stamped);
- `collectAndroidSystemChromeRunIndexes`, the run-condemnation rule that let one
  `clock` node condemn 95 unrelated ones;
- the leaf-id/prefix split, replaced by one container predicate that matches
  `status_bar`/`navigation_bar` as an id SEGMENT so the shade's own
  `split_shade_status_bar` counts.

Net −53 lines across 8 files, almost all of it classifier and prose.

Two alternatives were measured and rejected before this one (both recorded in
#1319 so they are not re-attempted):

- keying off AOSP window types, which this file's own TODO proposed:
  impossible. On a live device systemui reports `window-type=3` (TYPE_SYSTEM)
  collapsed AND expanded, `TYPE_STATUS_BAR` never appears, metadata is stamped
  on window roots only (1 of 169 nodes), and an expanded shade is ONE window
  holding the status icons and the tiles.
- condemning marked subtrees plus fully-condemned ancestors: leaks the unmarked
  chrome the walk re-parents beside the markers ("Battery 100 percent.", the
  notification-icon summary) — the ticking-clock regression #1319 warned about.

Test fixtures that modelled the old mechanism now model the device instead: the
synthetic status bar gets the `status_bar_launch_animation_container` root the
real capture has, and the content-recovery XML nests its chrome leaves inside
their container. Assertions were not weakened — settle.test.ts stamps via the
production predicate, and the #1319 test now asserts both walks classify
IDENTICALLY, which is the property that was missing.

Verified live on emulator-5554 (Pixel 9 Pro XL API 37, deskclock):
  - shade opening mid-settle: settled after 7655ms: +28 -25, tiles present,
    zero status-bar chrome (unchanged from before — settle was already right)
  - ordinary action, no shade: +1 -1, no chrome leak
  - real captures: collapsed status bar 9/9 chrome; expanded shade keeps every
    tile under BOTH walks, which is the behavior that changed

* fix(android): keep systemChrome provenance out of published nodes; simplify androidUiNodes

Review blockers on 8615771c6:

1. `systemChrome` reached CLI/SDK/MCP payloads: `serializeSnapshotResult`
   passed `result.nodes` straight through, so an internal classification became
   a de-facto public contract and per-node bytes. Added `publicSnapshotNodes`
   next to the field it strips, mirroring the existing
   `publicSnapshotCaptureAnnotations` projection, and applied it at that
   boundary (the single serializer both the CLI and capture output route
   through). The regression test asserts the published nodes deep-equal their
   input minus the field AND that the serialized JSON never contains the string;
   it fails if the projection is removed.

2. Fallow complexity on `androidUiNodes` (10 cyclomatic / 19 cognitive):
   extracted `createAndroidChromeSubtreeTracker`, which owns the depth and
   chrome-subtree state, leaving the generator a token loop. Not baselined.
   Note the tracker keeps open/close as distinct operations on purpose: a
   self-closing tag leaves at the depth it entered, a container descends and is
   left by its `</node>`, so collapsing them silently breaks nesting.

* refactor(android): let the chrome classifier read the tree, deleting the test-side stamping helper

Review: the remaining long comments read as workarounds. Three were, and each
pointed at code worth changing rather than explaining.

1. `collectAndroidSystemChromeIndexes` required the walk-stamped `systemChrome`
   even when the tree still held the container that defines it. Hand-built trees
   (every settle unit test) therefore classified as "no chrome at all", which is
   why they needed `withAndroidSystemChrome` — a test helper re-implementing the
   production descent, with a comment explaining why it had to exist.

   The classifier now reads the container from whichever source still has it:
   the parent chain when the capture kept it, the stamped flag when the walk
   dropped it. The helper, its type and its comment are deleted; no test
   re-states production logic, and any caller holding an intact tree is now
   classified correctly regardless of how it was produced.

2. `createAndroidChromeSubtreeTracker` tracked depth with arithmetic subtle
   enough to need a comment about self-closing tags — and subtle enough that an
   earlier revision of it was wrong. Replaced with a stack of open elements:
   push on descent, pop on `</node>`, nothing to explain.

3. `walkInteractiveOnlyAndroidFixture`'s 16-line comment still described the
   run-classification this branch deleted — stale, not merely long. Cut to what
   the helper does.

Also trimmed the contract, kernel, classifier and serialization-test comments to
the fact each states. Comment lines in the branch drop from 116 to 51.

No behavior change beyond (1), which strictly widens correct classification.
4318/4318 unit tests, lint, typecheck, build and smoke pass.

* fix(android): keep chrome provenance internal

* refactor(android): tighten chrome provenance seam

* fix(android): close chrome provenance response leaks

* test(provider): enforce chrome invariant across clients
2026-07-22 15:50:16 +02:00
Michał Pierzchała b1b26f3b6a feat: publish scripts from active sessions (#1357)
* feat: publish scripts from active sessions

* test: cover save-script force retargeting

* fix: address active publication review findings
2026-07-21 20:24:26 +02:00
Michał Pierzchała e7c02a9f4c feat: add advisory device claims (#1329)
* feat: add advisory device claims

* fix: preserve advisory claim ownership

* fix: retain claims after incomplete cleanup

* fix: retain claims across pre-open effects
2026-07-20 11:29:59 +02:00
Michał Pierzchała ef118b9d11 ci(test-app): fingerprint-keyed build cache — disk locally, Release artifacts in CI (#1321)
Splits the test app's build caching by context instead of running one remote
cache for both.

Locally, `expo run:*` caches the native build on disk via the
expo-build-disk-cache provider, keyed by the Expo fingerprint. A second run with
no native change reuses the first build; a screen edit never rebuilds, because
Metro serves JS. This is the original ask — "next time we don't build unless
native changes" — and needs no token, no network, and no custom provider.

In CI, test-app-build-cache.yml builds a Release binary per platform when the
fingerprint has no artifact yet, and publishes it as a GitHub Actions artifact
named `fingerprint.<hash>.<platform>`. Release, not dev-client, so the JS bundle
is embedded and a consuming job needs no Metro. setup-fixture-app installs it by
downloading the artifact and refreshing the JS with @expo/repack-app, so keying
on the native-only fingerprint stays correct — a JS-only change reuses the same
native binary in seconds. It falls back to an inline build when no artifact
exists yet, so a caller is never left without an app.

Release removes the sharp edges the dev-client cache needed. Its simulator .app
is universal (x86_64+arm64) rather than the active-arch-only slice a debug build
emits, so no architecture tag. It links against the SDK but loading is gated by
the deployment target, which the fingerprint already covers, so no toolchain
tag. And the CLI only narrows *debug* builds to the device ABI, so a Release APK
spans every ABI without the undocumented --all-arch flag. The artifact name
collapses to fingerprint plus platform.

This deletes build-cache-provider.js entirely — with it goes the custom Expo
provider that had to reach GitHub from inside @expo/cli, and every workaround
that forced: the fetch-nodeshim User-Agent shim, the arch/Xcode identity, the
upload-intent handoff. CI now talks to the artifacts API with plain `gh api`
outside the patched fetch, and locally the disk cache never hits the network.

The fingerprint comes from @expo/fingerprint's own `fingerprint:generate` (no
--platform, matching what @expo/cli hashes). Gitignoring /ios and /android is
what makes it machine-independent: the library asks the VCS whether the platform
markers are ignored and, concluding CNG, skips hashing them — so a developer's
prebuild output and a fresh CI checkout agree.

conformance-differential consumes setup-fixture-app, so it gains
`permissions: actions: read` for the artifact lookup.

The artifact lookup is non-fatal: a query outage leaves the id empty and
falls through to an inline build like a miss does, rather than exiting the
composite under set -e and turning a cache blip into a caller failure.
test/scripts/setup-fixture-app-fallback-smoke.sh drives that step's real shell
against a failing gh and asserts source=build; ci.yml runs it.
2026-07-18 09:36:32 +02:00
devin-ai-integration[bot] 6d99914f49 feat!: remove deprecated gesture duration and rotate velocity inputs (#1218, #1216) (#1315)
* feat!: remove deprecated gesture duration and rotate velocity inputs (#1218, #1216)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: address CI failures - remove dead export, dedupe positional validation, migrate linux-desktop swipe test to pan

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fixup! preserve Maestro swipe endpoint-hold execution profile via internal seam

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(adr): describe Maestro endpoint-hold internal seam in ADR 0013/0015

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: surface Maestro swipe executionProfile in replay trace and assert endpoint-hold in differential

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-17 12:29:41 +02:00
Michał Pierzchała 3fed8acda5 fix(remote): preserve tenant scope for proxy artifact downloads (#1317)
* fix(remote): preserve tenant scope for proxy artifact downloads

* docs(remote): clarify auxiliary tenant precedence

* refactor(remote): carry artifact request scope together

* fix(remote): bump daemon RPC protocol for tenant scope
2026-07-17 11:36:53 +02:00
Michał Pierzchała 9b5f333a25 fix(cli): deliver --no-record to the daemon (supersedes #1305) (#1311)
#1305 claimed to forward --no-record from "every recordable command reader".
Measured through the real argv -> reader -> client -> daemon chain on its own
merge commit, the flag reached the daemon for ONE command (`open`). It is now
5 of 33 on current main -- `open` plus get/is/find/snapshot, the latter four
only incidentally, because #1303 declared `noRecord` in their metadata.

#1305's fix was inert because it fixed a layer that is not load-bearing. Its
test asserted on `readInputFromCli` output -- an intermediate object two later
layers rebuild from scratch:

  1. `defineExecutableCommand.invoke` runs `metadata.readInput(input)` ->
     `readFieldInput`, which keeps ONLY declared metadata fields plus
     `readCommonInput`'s output. `noRecord` was neither, so it was filtered.
     (`open` survived solely because its metadata declares the field.)
  2. Each `to*Options` projection rebuilds the client options from
     `commonToClientOptions` plus its own named fields; that helper did not
     carry `noRecord` either.

So `--no-record` parsed, was accepted on every command, and was silently
dropped before dispatch -- including on press/click/fill and, per the
maintainer's review, gesture/back/home.

Fixed at the seams the flag must survive, not per reader:
  - `commonInputFromFlags` and `selectionOptionsFromFlags` (the reader layer has
    TWO parallel common helpers -- reader-input shape vs client-options shape --
    so both must carry it; `settings` used only the latter, which is why it was
    the last gap);
  - `readCommonInput` (stop `readFieldInput` filtering it);
  - `commonToClientOptions` (stop `to*Options` dropping it).

Measured after: 33/33 deliver the flag, with zero hand-listed commands.

#1305's `noRecordInputFromFlags` helper and all 13 hand-added call sites are
deleted: they are redundant against the seams, and leaving both would be two
sources of truth for one behavior -- exactly how the next gap breeds.

Preserves the --record asymmetry (ADR 0012 decision 6 amendment): --no-record is
common and rides the common seam; --record stays scoped to snapshot/get/is plus
a dynamically-validated find, on its own narrow helper. Also fixes `get
--record`, dead through the CLI since #1303 for the same re-projection reason
(`toGetOptions` rebuilds its options object), which that PR's daemon-level
scenario could not see.

Coverage is asserted where it is observable, not at the intermediate object:
  - `cli-record-flag-delivery.test.ts` drives real argv and asserts on the
    DAEMON REQUEST for all 32 recordable routes; it fails on reverting either
    seam ("press accepted --no-record but never delivered it to the daemon").
  - `no-record-recorder-routes.test.ts` is a healed-script regression: gesture/
    back/home with --no-record must not land in a written .ad. Reverted, it
    fails with the leaked `gesture "fling" "up" 100 200` line in the script.

A derived `recordsSessionAction` classification + completeness gate follows in a
separate PR: this fixes the 32, that makes a 33rd impossible.
2026-07-16 21:12:33 +02:00
Michał Pierzchała dd153a6233 fix(replay): default-exclude observation-only reads from repair heals, add --record opt-in (#1271 stage 2) (#1303)
* fix(replay): default-exclude observation-only reads from repair heals, add --record opt-in (#1271 stage 2)

Amends ADR 0012 decision 6: snapshot/get/is/a read-only find are excluded
from a repair-armed heal by default (session.saveScriptBoundary set), never
from ordinary open --save-script authoring recording. wait keeps recording
(flow timing, not observation).

The corrective-read trap (wave-3 E3: the diverged step was itself a get)
means blanket read-exclusion is unsafe, so a new --record flag forces one
action through when the correction is itself a read. --record/--no-record
are mutually exclusive (INVALID_ARGS if both are set) and are plumbed
identically across CLI, the Node client, and MCP.

The exclusion lives at the single daemon-side choke point
(recordActionEntry/isExcludedRepairSegmentObservation), so an excluded read
never grows session.actions.length -- the same counter the existing
record-and-heal resume watermark (describeUnperformedRecordAndHeal) already
checks, so the empty-segment fail-loud guard falls out for free (message
updated to mention --record).

Also fixes a latent bug found along the way: the get/is/find/snapshot CLI
readers never forwarded --no-record/--record into the built request (only
`open` did), so stage 1's "use --no-record" guidance was silently inert via
the CLI.

* test(integration): cover --record with a provider-backed repair-segment scenario (#1271 stage 2)

The progress ratchet (test:integration:progress:check) flagged `record` as an
unclassified public CLI flag. Classifying alone would only trade that failure
for "missing Provider-backed integration workflow flag coverage" -- and the
exclusions bucket is for config/output/transport flags, not behavior flags, so
using it would dodge the ratchet rather than satisfy it.

Adds a focused provider-backed scenario instead, next to the `--no-record`
precedent in android-lifecycle.test.ts. It drives the real request router,
session store, replay runtime, and script writer (only the ADB provider is
faked), and proves the flag's actual purpose end-to-end: inside a repair-armed
`replay --save-script` segment that diverged, the SAME `get text <selector>`
runs twice differing only in `--record`; exactly one line lands in the
committed healed .ad. Also asserts `--record` + `--no-record` is INVALID_ARGS.

Verified the scenario reproduces the bug: with the exclusion neutered it fails
on "a diagnostic read inside a repair segment must not be recorded".

* fix(replay): key the repair-segment exclusion on provenance, scope --record (#1271 review)

Addresses the maintainer review on #1303.

P1 — the exclusion dropped PLANNED reads from the heal. It discriminated by
command class, but the real discriminator is provenance. Replayed plan steps
dispatch through the ordinary request path, so an authored get/is/find step hit
the same recordIfSession -> exclusion path as an interactive read and never
reached session.actions -- and the heal IS session.actions.slice(boundary). A
repaired flow therefore replayed its authored `is visible` assertion and then
silently dropped it from its own healed script: the heal quietly stops checking
what it used to check, which for a 10x-QA-replay suite is the worst failure
mode.

Fix: an explicit provenance marker, not a heuristic. `internal.replayPlanStep`
is stamped by invokeResolvedReplayAction -- the single point every plan step is
dispatched, so it covers annotated and unannotated steps alike. `internal` is
daemon-only (toDaemonRequest never copies it off the wire), so authored
provenance cannot be spoofed; same channel as replayTargetGuard. The rule now
lives once in isInteractiveObservation and both recording call sites consume it,
so the mock fixture uses the production classifier instead of mirroring it.
Planned observations survive automatically -- users never annotate their own .ad
steps.

--record is no longer a common flag: removed from
COMMON_COMMAND_SUPPORTED_FLAG_KEYS, statically scoped via allowedFlags to
snapshot/get/is, and validated dynamically for find (read-only allows; a
mutating find click|fill|focus|type is INVALID_ARGS before any device work,
sharing one isReadOnlyFindAction predicate with the read-only routing so the two
cannot disagree). --no-record stays shared -- it applies to every recordable
command. Removed from `open`, which is never observation-only.

Rebased onto #1304 and dropped the four hand-rolled reader blocks. Split its
helper rather than broadening it: noRecordInputFromFlags (all 13 readers) +
observationRecordInputFromFlags (snapshot/get/is/find only). Two named helpers
over one `allowRecord` policy arg -- the capability is then the helper's NAME, so
a mutating reader physically cannot forward --record, whereas a policy arg would
let a future mutating reader opt in by flipping a literal with no schema change.

ADR-0012 decision 6 now states the provenance rule, not a command-class rule.

The scenario gates the P1: its authored step is a distinguishable `is visible`,
and it fails without the provenance check ("the authored 'is visible' step must
survive the heal").

* test(daemon): pin that wire-supplied `internal` never reaches a daemon request

#1271 stage 2 made `DaemonRequest.internal` semantics-affecting:
`internal.replayPlanStep` decides whether an observation-only command is an
authored plan step (kept in a repair heal) or an out-of-band diagnostic
(excluded). That makes "internal means internally-stamped" worth pinning
rather than leaving to convention.

The invariant already holds, structurally and twice over: the boundary's
`commandRpcParamsSchema` is an allowlist projection emitting only its eight
named fields, and `toDaemonRequest` then builds the request field by field.
Neither can carry `internal` off the wire.

This posts a real JSON-RPC request carrying
`internal: { replayPlanStep: true }` through a loopback server and asserts the
dispatched request has no `internal`. Verified it fails
("a wire-supplied `internal` must never reach the daemon request") when both
allowlists are regressed, so it guards the composite contract instead of
restating one layer.
2026-07-16 20:31:05 +02:00
Michał Pierzchała a6789d086b docs: clarify keyboard dismiss fallbacks (#1302)
* docs: guide iOS keyboard blur fallback

* docs: simplify iOS keyboard blur guidance

* docs: clarify keyboard dismiss fallbacks

* fix: make keyboard fallback skillgym cases decisive
2026-07-16 20:15:47 +02:00
Michał Pierzchała 95f838f514 test: stop pinning settle capture counts against a wall-clock loop (#1307)
* test: model the UI in settle-observation fixtures instead of a capture count

settle-observation's "contention flakes" were a zero-margin comparison
meeting a 1ms clock skew, not generic load.

runStableCaptureLoop derives pollMs = min(300, max(25, quietMs)), so the
test's settleQuietMs: 25 made pollMs === quietMs, and the settle check
(one sleep(25) plus capture time, against >= 25) a 0ms margin. Node's
setTimeout(25) advances Date.now() by only 24ms in 0.13% of calls idle
and 0.63% under load, because libuv's timers and Date.now() read
different clocks. On a 24 the loop takes a third capture that the
transcript never scripted, and settle's best-effort catch reports the
resulting throw as settled: false.

The fixtures now model the surface rather than the runner's speed: a
quiet UI serves the settled tree to every capture, a busy one a fresh
tree per capture, via new transcript `repeat` entries and result
factories. The snapshot-floor economy guard survives as a bound (2-3
captures), and the follow-up's "no fresh capture" cost — previously
implied by the exact transcript — is now asserted directly.

Production is untouched: the same zero margin only costs a wasted extra
capture and poll there, filed separately as #1306.

* test: stop pinning the settle capture count in the iOS contract scenario too

A sweep for the same bug class found direct-ios-selector's settleObservation
scenario carrying the identical 0ms margin: settleQuietMs: 25 (so pollMs ===
quietMs) against a consume-once transcript scripting exactly two settle
captures, with no injected clock. It has not lost the coin flip in CI yet,
but it fails the same way when it does — a third capture finds no entry and
settle's best-effort catch reports settled: false.

Same fix: the fixture models a quiet UI (every settle capture sees the same
tree) instead of scripting how many captures fit in a wall-clock window.

The rest of the sweep was clean. The other contract scenarios and the
interaction runtime tests already use clamped mocks that repeat the last
snapshot, so any capture count is tolerated; the fake-clock tests are correct
to pin exact counts.

* style: oxfmt quietRunnerSnapshotEntry signature

* test: make one-shot-outranks-repeat a real transcript rule (P2 review)

The review is right: `one-shot entries still outrank a repeat entry` asserted
a guarantee the lookup did not provide. It passed only because the one-shot
happened to be declared first — unordered lookup took the first match, so a
repeat declared ahead of a matching one-shot shadowed it forever and left it
permanently unconsumed. Both failure modes reproduce; the reverse-order test
added here fails on the previous implementation.

Unordered lookup now searches matching one-shots before repeats, so
outranking holds whatever the declaration order. A repeat is documented as
its command's fallback.

Ordered transcripts now reject repeats at construction: ordered lookup only
ever reads the head, so a repeat there never advances and strands every entry
behind it. Refusing the combination beats failing later as a confusing
"Provider command mismatch".

Coverage added for both: reverse declaration order, and ordered + repeat.
2026-07-16 20:13:07 +02:00
Michał Pierzchała 117f78107e feat: add direct Limrun provider runtime (#1278)
* feat: add direct Limrun cloud runtime

* refactor: reuse Android provider runtime for Limrun

* refactor: pass runner context to provider runtimes

* fix: remove Android gesture swipe fallback

* fix: reconcile Limrun direct runtime with main

* refactor: compose Android provider interactors in core

* fix: satisfy packaged Limrun runtime checks

* perf: load Limrun provider runtime on demand

* docs: document Limrun device cloud flow

* refactor: reuse Android reverse provider for Limrun

* fix: isolate provider-owned iOS sessions

* fix: preserve provider runtime boundaries

* refactor: split close repair lifecycle

* fix: reject unavailable provider leases

* fix: reconcile provider runtime review feedback

* test: stabilize alert deadline smoke assertion

* fix: recover expired provider leases

* fix: limit Limrun to remote simulators

* fix: make Limrun provider cleanup durable

* test: cover Limrun connect through CLI

* fix: make provider expiry recovery durable

* refactor: remove Limrun compatibility cleanup

* fix: release live provider leases on expiry
2026-07-16 19:26:20 +02:00
Michał Pierzchała b10c8cfb3a fix: warn agents that armed-repair diagnostic reads are recorded too (#1271 stage 1) (#1287)
During an armed `--save-script` record-and-heal repair, read-only diagnostics
an agent runs to locate the corrected target (snapshot -i, get attrs, find,
is) are recorded into the healed script by default, alongside the corrective
press. The wave-3 E3 repair-economics experiment measured 0/4 trials
producing a clean healed script hands-off, and one recorded `get attrs`
caused a second, self-inflicted identity-mismatch divergence on fresh replay.

This is stage 1 of the maintainer's two-stage triage on #1271: safe interim
guidance only, no recording-behavior change. Stage 2 (defaulting read-only
commands out of the repair transaction) stays gated on an ADR-0012 amendment.

- divergence.ts: buildRepairHintGuidance appends a diagnostics --no-record
  clause to every repairHint's text guidance, gated on
  resume.repairSessionHeld === true (decision 6, R7 C1's armed-repair signal)
  so it never renders on a plain, non-repair divergence.
- cli-help.ts: the "Agent-supervised repair (heal-by-doing)" section in
  `help workflow` now says the same thing.
- Unit coverage: divergence.test.ts asserts the clause is present iff
  repairSessionHeld is true, across record-and-heal/state-repair/caution/
  manual.
- SkillGym regression: agent-device-smoke-suite.ts adds
  record-and-heal-diagnostics-no-record, verified 3/3 against claude-haiku
  and codex-mini live runners.
2026-07-16 13:48:08 +02:00
devin-ai-integration[bot] 8246362999 chore: baseline-free production-exports cleanup (#1276) (#1282)
* chore: baseline-free production-exports cleanup (#1276)

Classify and burn down the 32 baseline-tolerated unused production exports.

- Live seams: annotate with @internal JSDoc visibility tags (test hooks,
  introspection helpers, public install-source constant) so fallow no longer
  treats them as dead production exports.
- Wrappers: collapse re-export wrappers in commands/index.ts (ref/selector)
  and daemon/lease-context.ts (buildLeaseDiagnosticsContext); update all
  importers to pull directly from the source module.
- Stale baseline entry: remove the non-existent
  resetAndroidMultiTouchHelperInstallCache entry.
- Empty fallow-baselines/production-unused-exports.json so
  check:production-exports now fails loudly on any new dead export.

Fixes #1276

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: address review feedback on production-exports cleanup (#1276)

- CONTRIBUTING.md: document that intentional non-production exports should use
  JSDoc @internal with a short justification, treated as a reviewed baseline entry.
- isPlatform: fix JSDoc tag to "@internal" and remove conflicting "public" wording.
- ARCHIVE_EXTENSIONS: re-export from src/sdk/install-source.ts so the public
  install-source subpath has a real consumer story for the constant.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: make production-exports check truly baseline-free (#1276)

- Drop --baseline from pnpm check:production-exports and remove the
check:production-exports:baseline generation script.
- Delete fallow-baselines/production-unused-exports.json.
- Update CONTRIBUTING.md to describe the baseline-free behavior and remove
references to reviewed baseline entries for production unused exports.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-16 11:44:07 +02:00
Michał Pierzchała e58cbcdb5f refactor: colocate native platform sources under android/, apple/, linux/ (#1273)
Move the scattered root-level native projects into per-platform folders and drop
the now-redundant platform prefix:

- android-ime-helper/        -> android/ime-helper/
- android-multitouch-helper/ -> android/multitouch-helper/
- android-snapshot-helper/   -> android/snapshot-helper/
- apple-runner/              -> apple/runner/
- macos-helper/              -> apple/macos-helper/
- src/platforms/linux/atspi-dump.py -> linux/atspi-dump.py

Only repo source paths move. Identity surfaces stay frozen so no user's runner
cache is invalidated on upgrade: the derived-cache key hashes source paths
relative to AgentDeviceRunner and excludes packageVersion, and the
~/.agent-device/{apple-runner,macos-helper} namespaces, the
agent-device-android-*-helper artifact/manifest/protocol names, the
AgentDeviceRunner Xcode project, and the `prepare ios-runner` CLI command are
unchanged. Updates build/package scripts, CI, package.json files+scripts,
ignore/attr/fallow configs, runtime path resolvers, and test fixtures.

Also: re-base repo-root-relative refs inside the moved apple/runner for the
added nesting level (gated XCUITest fixture walk + two doc links), and clean the
legacy dist/apple-runner packaged output so the relocated runner can't
double-ship into the wholesale-included dist (with a regression test).
2026-07-15 21:47:38 +02:00
Michał Pierzchała 37895caf99 refactor: replace Maestro compat with typed direct engine (#1217)
* test: add pinned Maestro conformance harness

* feat: add typed Maestro program IR parser

* docs: define direct Maestro engine architecture

* test: compare Maestro oracle with typed IR

* feat: add direct Maestro program engine

* refactor: narrow Maestro execution context

* refactor: tighten Maestro program parsing

* fix: verify iOS Maestro visibility waits

* refactor: isolate retained Maestro runtimes

* refactor: type Maestro target resolution

* refactor: harden typed Maestro execution

* refactor: share in-page swipe planning

* feat: add typed Maestro runtime port

* refactor: parse Maestro suite metadata from typed IR

* refactor: centralize Maestro include loading

* feat: execute Maestro files through typed engine

* refactor: share replay built-in variables

* fix: make Maestro target intent explicit

* fix: refresh Maestro targets before input

* refactor: format Maestro progress from typed IR

* feat: compile typed Maestro replay plans

* feat: bind typed Maestro runtime to public commands

* feat: route Maestro YAML through typed runtime

* refactor: remove legacy Maestro runtime

* refactor: remove obsolete replay control model

* refactor: split typed Maestro plan modules

* fix: harden typed Maestro runtime semantics

* docs: update direct Maestro architecture

* fix: reconcile Maestro runtime with merged contracts

* fix: harden typed Maestro execution boundaries

* fix: harden typed Maestro runtime evidence

* perf: avoid eager Maestro device resolution

* refactor: finalize typed Maestro execution

* fix: reject Android system-only helper snapshots

* fix: preserve Android system dialog snapshots

* fix: make helper-backed CI deterministic

* refactor: invalidate Maestro observations before dispatch

* fix: make Maestro selector policy explicit

* refactor: remove Maestro ranking sentinels

* refactor: make Maestro own observation stabilization

* refactor: source Maestro compatibility presets

* refactor: keep Maestro failure reports typed

* refactor: simplify Maestro runtime policy

* fix: isolate Maestro engine failures

* refactor: consolidate Maestro swipe presets

* fix: align Maestro selector and observation semantics

* fix: preserve atomic iOS Maestro taps

* fix: require semantic uniqueness for Maestro taps

* fix: preserve Maestro parse provenance

* docs: pin Maestro compatibility presets

* docs: reconcile Maestro gesture viewport contract

* perf: resolve Maestro gesture viewport directly

* test: align Maestro replay regressions

* fix: order Android gesture lift after endpoint

* fix: settle Maestro gestures before continuation

* fixup! fix: order Android gesture lift after endpoint

* refactor: normalize Maestro swipes once

* refactor: fail impossible Maestro observations

* refactor: normalize Maestro defaults alias

* test: reconcile Android provider scenarios

* fix(android): synchronize single-pointer move events

* test: align repair digest parsing

* refactor: type Maestro runtime operations

* refactor: keep Maestro controls compact

* refactor: name Maestro diagnostic limit

* fix: align Maestro parser and settling semantics

* fix: complete Maestro compatibility semantics

* docs: define Maestro compatibility boundaries

* fix: refresh iOS runner target after relaunch

* fix: reset prewarmed iOS runner after URL open

* fix: preserve iOS Maestro target and swipe intent

* fix: harden direct Maestro runtime semantics

* fix: preserve ranked Maestro replay suggestions

* fix: align maestro tap runtime semantics

* fix: stabilize maestro ci contracts

* fix: tighten maestro runtime architecture

* fix: reconcile maestro replay with latest main

* perf: tighten Maestro iOS stabilization

* fix: preserve Maestro app lifecycle sessions

* fix: restore Maestro CI coverage

* fix: address Maestro engine review findings

* refactor: consolidate Maestro compatibility internals

* fix: scope Maestro target evidence to childOf
2026-07-15 21:26:42 +02:00
Michał Pierzchała 22a3c4711c refactor(daemon): remove the superseded coarse snapshotRefsStale marker (ADR 0014 step 8) (#1268)
* refactor(daemon): remove the superseded coarse snapshotRefsStale marker (ADR 0014 step 8)

The coarse `snapshotRefsStale` client-stale marker is fully superseded by the
ref-frame model and is removed:

- `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it —
  replacing the latest observation is a read that never touches the frame.
- Read-only ref staleness now derives from frame state: a plain ref warns once
  the frame has EXPIRED (a device side effect changed the screen), and a
  read-only capture no longer marks refs stale because it does not expire the
  frame. Pinned-ref warnings keep comparing against the frozen frame epoch.
- Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker)
  and the `session.snapshotRefsStale` field.

Migrates every test off the marker to the frame model (frame-expiry drives the
read warning; complete/partial activation drives admission), and updates the
ADR status + module docs to record step 8 as landed. Ships as follow-up to the
merged #1257 since that PR closed before this step.

Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean.

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

* fix(daemon): resolve @ref reads from the frame tree; scope find's internal warning

Address three review blockers on the coarse-marker removal (ADR 0014 step 8):

1. @ref reads now bind against the authorized frame tree
   (`refFrameSnapshot ?? snapshot`) in `requireSnapshotSession`, so an
   internal read-only capture that replaced the observation cannot let a
   plain `@eN` resolve a different element by positional coincidence.
   Missing frame evidence fails instead of falling through to a newer
   observation.

2. A mutating find's internal leaf dispatch (`internal.findResolvedTarget`)
   no longer attaches a stale-ref warning in either the press or fill path —
   the caller never consumed a `@ref`, so the public find response must not
   claim it did.

3. `resolveRefStalenessWarning` checks frame expiry FIRST, matching the
   admission order: an expired frame is stale for any ref, even a pin that
   matches the epoch (a matching pin proves identity within the retained
   frame, not that the UI is current).

Regressions: divergent observation-vs-frame trees resolve from the frame
tree or fail when evidence is missing; a locator-based mutating find from an
expired frame carries no stale-ref warning; the reordered resolver unit test.

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

* fix: correct stale-ref warning comments and ADR-0014 present-tense marker refs

The get/wait dispatch comments in selector-runtime.ts still described the
superseded coarse snapshotRefsStale marker ("warn when that tree was
replaced since the client last received refs") even though staleness is
now derived from ref-frame expiry (ADR 0014 migration step 8). Reworded
both to describe the frame-derived mechanism actually implemented by
resolveRefStalenessWarning.

session-snapshot.ts's early-return comment in markSessionPartialRefsIssued
referenced "the coarse marker" as something still left untouched, but that
field no longer exists — reworded to name the ref frame fields it actually
preserves.

ADR-0014's "Ref frames are separate from operational observations" section
still described snapshotRefsStale as part of "the existing... implementation"
in present tense, contradicting the Decision section's own note (line 39)
that migration step 8 already removed it. Reworded to keep the historical
mention while stating the removal.

* fix: frame-lifetime wording for the stale-ref warning and read comments

Address the follow-up review blocker plus the co-located terminology cleanup
(ADR 0014 step 8):

- STALE_SNAPSHOT_REFS_WARNING no longer claims "the session snapshot changed";
  it now describes frame lifetime in terms valid for both read warnings and
  mutation rejection — the UI may have changed since the refs were issued, so
  take a new snapshot before relying on or interacting with them. The warning
  fires on frame expiry, including device side effects where no stored snapshot
  changed.
- selector-runtime.ts: the get/wait @ref comments now say the read binds to the
  retained ref-frame evidence and its staleness is frame-derived, not a property
  of the stored snapshot or the live polling capture.
- settle.ts: an unsettled stored capture replaces the observation without
  touching the ref frame; read staleness is driven by side-effect-seam expiry,
  not by storing a fresh observation.
- interaction-settle.test.ts: renamed the settle test off the removed
  stale-marker language to "activates a partial ref frame" (what it asserts).

Comments/test-name/warning-text only — no runtime behavior change.

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

* fix(daemon): name the ref-frame epoch in the pinned-stale-ref warning

The pinned-ref warning is compared against refFrameEpoch(session) — the frozen
frame epoch — not the latest observation generation, and after a read-only
capture those two diverge. The message still said "the session tree is now sN",
which is ambiguous once the observation counter has advanced past the frame
epoch. Name the ref-frame epoch instead:

  Ref @e12 was minted from snapshot s3 but the session's ref frame is now s15 —
  re-run snapshot -i.

Renames the builder param to `currentFrameEpoch` and corrects its doc comment to
say the pin is compared against the frame epoch, not the stored tree generation.

Regression: `resolveRefStalenessWarning` names the frozen frame epoch, not the
bumped observation generation — a read-only `setSessionSnapshot` advances the
observation counter (15 -> 16) while the frame epoch stays frozen at 15; a pin
at s15 is clean and a pin at s12 names s15, never s16.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 20:55:15 +02:00
Michał Pierzchała 54977f3b87 feat(daemon): ADR 0014 session ref-frame lifetime — full implementation (#1257)
* feat(daemon): classify ref-frame effect on every daemon command (ADR 0014 step 2)

Add the ADR 0014 `refFrameEffect` trait to the daemon command descriptor
facet: every command that reaches a session-owning daemon leaf declares how
it relates to the session's authorized ref frame — `preserve`,
`may-invalidate`, `delegated`, or a request-sensitive resolver for
subaction-dependent commands (keyboard status vs dismiss, alert get/wait vs
accept/dismiss).

This is the honesty/completeness guard, not the transition site: a
`may-invalidate` command still calls the (future) ref-frame module only when
its mutating path runs. No runtime behavior changes here.

- `RefFrameEffect` / `DaemonRefFrameEffect` types and a `resolveRefFrameEffect`
  accessor honoring the resolver form, mirroring the existing closure traits.
- Classify all 58 daemon-faceted commands; `find` is the honest superset
  (`may-invalidate`) pending a read/mutate resolver during enforcement wiring.
- Give `app-switcher` a daemon facet (route unchanged) so the generic-fallback
  escape hatch the ADR calls out is covered instead of silently unclassified;
  drop it from parity's UNROUTED set.
- Completeness gate (`ref-frame-effect.test.ts`): every daemon-projected
  command classifies an effect, every public command is classified or in the
  explicit non-daemon allowlist (`install-from-source`, which projects via the
  `install_source` internal command), and the resolvers/app-switcher resolve as
  declared.

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

* feat(daemon): introduce ref-frame module + admission matrix (ADR 0014 step 1)

Introduce `src/daemon/ref-frame.ts` as the single owner of the ADR 0014
ref-frame model — the authorization namespace for mutation refs, kept distinct
from the latest operational observation (`session.snapshot`). It defines the
frame's issuance scope and lifecycle state and the pure mutation-admission
matrix (`admitRefMutation`) with the ADR's typed, order-sensitive reasons:
ref_frame_expired, ref_generation_mismatch, plain_ref_requires_complete_frame,
ref_not_issued.

The frame is introduced behind the existing `snapshotGeneration` (epoch) and
`snapshotRefsStale` (coarse client-stale) fields, whose wire-visible names
(`refsGeneration`, the `@e12~s42` pin grammar) are unchanged. New
`refFrameState`/`refFrameScope` session fields default to active/all, so the
matrix currently reduces to the generation-pin check the iOS path already did —
no behavior change. Expiration at the side-effect seam and non-`all` scope land
in later steps.

The existing #1241 iOS stale-ref guard now routes its decision through
`admitRefMutation` (plus the transitional coarse-stale check for plain refs),
so the module is production-live; the external error contract is identical.
Adds a unit test covering the full admission matrix and reason ordering.

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

* feat(daemon): wire pre-side-effect frame expiration at the seams (ADR 0014 step 3)

Route device mutations through the idempotent ref-frame transition. A leaf
expires the current frame synchronously, immediately before awaiting the device
operation, so success, timeout, cancellation, or connection loss all leave it
expired — there is no success-only rollback.

Seams wired:
- interaction runtime backend closures (tap/click, fill, longPress, native web
  clickRef/fillRef, gesture, type) — post-resolution, pre-dispatch, so a
  resolution failure before the seam preserves the frame;
- the generic daemon leaf (back/home/rotate/scroll/tv-remote/app-switcher/
  viewport/focus, ...), gated by the daemon `refFrameEffect` classification via
  `resolveRefFrameEffect`, which is that resolver's first production consumer.

Re-authorization: issuing a complete namespace re-activates the frame —
`markSessionSnapshotRefsIssued` and the snapshot command's
`buildNextSnapshotSession` — so a fresh capture between mutations restores
usability. A diff or kept tree preserves the prior authorization state; internal
read captures never re-authorize.

Enforcement of the new expired-frame rejection is intentionally deferred to step
7, which the ADR gates on fresh live device evidence per platform. The iOS
#1239 guard therefore stays armed-but-not-enforced here: it consults the
admission matrix but still rejects only on the pre-existing conditions (pinned
generation mismatch, coarse plain-ref stale marker), so behavior is unchanged.
Tests prove the transition is wired (a press expires the frame; a re-issue
re-activates it) alongside the idempotency and re-authorization unit tests.

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

* fix(daemon): address ADR 0014 review — partial issuance, keyboard, seam coverage

Exact-head review found three blockers; all fixed with focused seam tests.

1. Partial issuance no longer restores complete authority. Every caller of
   `markSessionSnapshotRefsIssued` (find, settled diff, replay divergence) is a
   PARTIAL publication, but it re-activated a complete `all`-scope frame. It now
   only clears the coarse marker; complete re-authorization is reserved for the
   snapshot command (`activateCompleteRefFrame`, from `buildNextSnapshotSession`).

2. Keyboard resolver covers every mutating subaction. keyboard accepts
   status/get/dismiss/enter/return; only status/get read, so dismiss/enter/return
   (enter/return dispatch a real return key) are now `may-invalidate`. Alert reads
   are likewise a named set. Completeness test extended.

3. Remaining step-3 leaf seams wired: the direct iOS selector fused dispatch, the
   direct `find` focus/type dispatches (find click/fill already delegate through
   the interaction leaf), and Android blocking-dialog recovery (expire before the
   recovery tap). Focused seam tests for each prove the frame expires.

Enforcement of the expired-frame rejection remains deferred to step 7 behind the
ADR's per-platform live-evidence gate; behavior is unchanged.

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

* feat(daemon): cross the seam at every specialized mutating leaf (ADR 0014 step 3 complete)

Wire expireRefFrame at the remaining may-invalidate leaves so EVERY mutating
daemon leaf crosses the side-effect transition, not just the interaction/generic
paths:

- keyboard dismiss/enter/return, push, trigger-app-event (shared session leaf) —
  gated by resolveRefFrameEffect so keyboard status/get preserve the frame;
- alert accept/dismiss (get/wait preserve, via the alert resolver);
- settings mutations;
- React Native overlay dismissal;
- install / reinstall (deploy op);
- open / relaunch — expires the reused session's frame before the launch;
- close — expires for uniformity, though a successful close deletes the whole
  session (and its frame) anyway.

Seam tests: keyboard dismiss expires while status preserves (proves the
resolver-gated pattern), and RN overlay dismissal expires. Enforcement stays
deferred; behavior unchanged.

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

* feat(daemon): partial issuance scope + MCP pin retention + pinned CLI refs (ADR 0014 step 4)

A find/settled-diff/divergence result publishes only the refs it returned, so it
now activates a bounded PARTIAL frame authorizing exactly those ref bodies
(`markSessionPartialRefsIssued`) instead of nothing — a plain ref then requires a
complete frame and a pinned ref outside the set is rejected. An empty partial
result leaves prior authority intact.

- read-only find publishes its one ref; settled diff publishes its added lines +
  `refs` + `tail`; divergence publishes its capped, non-covered, non-chrome
  digest set.
- MCP: a mutating `find` returns no `refsGeneration` and is explicitly
  non-issuing — it no longer hits the missing-generation branch that wiped the
  whole per-session pin scope (forwarding the old pin is how the daemon produces
  a precise stale rejection).
- Human-CLI partial results render reusable refs in ready-to-copy `@eN~s<gen>`
  form (find + settled tail); JSON/Node keep plain bodies + one response-level
  generation, and MCP stays plain (it auto-pins). Output-economy waiver covers
  the +8-byte tail-pin increase with an ADR justification; the workflow oracle
  treats a pinned ref as surfacing its plain body.

Enforcement of the frame's expiry and partial-scope rejections stays deferred to
step 7 (behind the ADR's per-platform live-evidence gate), so this is
behavior-preserving; the iOS guard now consumes the admission verdict for a
typed `details.reason` on the rejections it already emitted.

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

* feat(daemon): resolve refs against the authorized frame tree (ADR 0014 step 5)

Retain the ref frame's immutable source tree (shared reference, no deep
copy) and resolve a `@ref` against it rather than the latest operational
observation. An Android freshness — or any read-only — capture advances
`session.snapshot` without disturbing the frame tree, so the two
intentionally diverge.

At resolution, adopt the fresh observation's node (its current on-screen
coordinates) ONLY when its local identity still matches the authorized
node — the legitimate "element moved" case. If a different element now
sits at that index, keep the authorized frame node so a positional
coincidence cannot retarget the action.

Expose the frame tree to the command runtime through
`CommandSessionRecord.refFrameSnapshot`; pre-frame sessions fall back to
`snapshot` and behave exactly as before.

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

* feat(daemon): fail-closed ref-mutation enforcement across platforms (ADR 0014 step 7)

Enforce the ref-frame admission matrix on every platform before dispatch:
an expired frame, a superseded generation pin, a plain ref against a
partial frame, or an unissued pinned ref is now rejected with a typed
`details.reason` and an honest message that names the lifetime failure
instead of claiming the ref was missing or lacked bounds. The prior
iOS-only, coarse-marker guard is replaced.

Freeze the frame epoch at issuance (`refFrameGeneration`) so a later
read-only capture that advances the observation counter cannot falsely
reject a correct pin from the issuing frame; staleness warnings compare
against the same frame epoch.

A mutating `find` re-resolves its target by locator against a fresh
capture, so its internal leaf dispatch carries `internal.findResolvedTarget`
and skips ref admission (it still crosses the seam and expires the frame).

Update unit and provider-integration scenarios to the new contract:
multi-mutation ref sequences re-observe between mutations, settled refs are
consumed in pinned form, and rejections assert the typed reason.

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

* docs(adr-0014): promote ref-frame vocabulary and mark implementation status

Flip ADR 0014 to Accepted, promote the ref-frame / frame-expiry-seam /
mutation-admission vocabulary into CONTEXT.md, correct the `@ref`
resolution note to the frame-tree model, record the migration status
(steps 1–7 landed; coarse-marker removal follows live-evidence
confirmation), update ADR 0012's divergence-ref amendment to accepted,
and add a CHANGELOG entry for the fail-closed ref lifetime.

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

* test(daemon): lock ADR 0014 evidence #1 and refresh module docs

Add a daemon-level sequence test proving the canonical contract: after an
unobserved first ref mutation, a second mutation rejects both bare and
pinned with ref_frame_expired, and a fresh snapshot re-authorizes. Refresh
the ref-frame module header and seam-expiry test comment now that
enforcement is live.

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

* fix(daemon): address ADR 0014 exact-head review — six lifetime blockers

1. Android dialog recovery aborts an outstanding ref action: a ref
   press/fill admitted against the pre-recovery frame now fails with
   ref_frame_expired when before-command recovery mutates the UI, instead
   of continuing against the recovered screen (selector/coordinate actions
   still re-resolve and continue).
2. open --relaunch expires the existing session's frame BEFORE the close
   dispatch, so a close timeout/failure that already tore the app down
   still leaves the old frame expired.
3. expireRefFrame clears scoped-snapshot lineage (snapshotScopeSource) at
   the seam, so snapshot -s @ref -> mutation -> snapshot -s @same-ref can
   no longer borrow stale lineage across a device side effect.
4. Missing authorized-frame evidence fails closed: resolveSnapshotForRef no
   longer recaptures and accepts the same ref body from a newer tree by
   positional coincidence. A mutating find's internal dispatch resolves
   against its own fresh capture (omitRefFrameSnapshot), not the frame.
5. Mutating find omits refsGeneration — its acted ref is diagnostic
   pre-action identity and must not be pinnable after the action.
6. An empty partial publication leaves all session state untouched
   (including the coarse marker), instead of clearing it before finding
   there were no refs to issue.

Adds focused regressions (lineage-cleared sequence, empty-partial no-op,
fail-closed on unusable bounds, in-frame label recovery, mutating-find
non-issuance) and extracts the find action dispatch to keep complexity in
budget.

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

* fix: preserve snapshot refsGeneration + shared recovery rejection (ADR 0014 re-review)

P1: structured JSON/Node snapshot results now retain the response-level
refsGeneration. It was declared on the daemon response but dropped by the
public CaptureSnapshotResult type, the serializer, and the Node normalizer,
so default `snapshot -i --json` emitted refs with no generation to pin
against. Added to the type, serializer, normalizer, plus CLI/Node tests.

P2: Android dialog-recovery abort now reuses the SHARED admission rejection
(refMutationAdmissionResponse) instead of a bespoke error, so the failure
carries the full typed context (reason, ref, currentGeneration, scope,
mintedGeneration) identical to every other expired-frame rejection across
platforms. Removes the now-unused AppError/refFrameState imports. Adds a
regression proving recovery aborts the outstanding ref action before any
press dispatch.

Also adds the relaunch failure-boundary regression (existing-session close
fails after dispatch → old frame stays expired), and corrects the ADR
implementation-status note so Android blocking-dialog recovery and a real
provider-backed interaction/lifecycle are recorded as unexercised release
blockers rather than confirmed enablement.

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

* docs(adr-0014): record provider seam as live-verified; Android recovery sole blocker

The provider-backed interaction + lifecycle seam is now confirmed by fresh
live evidence (AWS Device Farm, webdriver backend). Update the ADR
implementation-status note so only Android blocking-dialog recovery remains
an unexercised release blocker — and note it is blocked on a bootable free
Android target plus a deterministic app-owned ANR trigger, not on any code gap.

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

* docs(adr-0014): record Android ANR recovery as an accepted evidence gap

Per the review decision: the Android blocking-dialog recovery seam has no
deterministic app-owned ANR repro in the harness, so it was not live-
exercised. The team accepted shipping without a live run for it — its
transition/abort logic is covered by fixture regressions and it is enforced
in code identically to the verified paths. Reword the status note from an
open release blocker to a documented, accepted evidence gap, which unblocks
step 8's coarse-marker removal.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-14 21:25:11 +02:00
Kenichi Saito 236016ed8a fix(ios): support remote-hosted alerts on physical devices (#1232)
* fix(ios): probe remote-hosted system modals (AccessorySetupKit picker) when the springboard mirror yields no hittable actions

* fix(ios): fail closed on host state, guard dismissal re-query, unit-test probe routing

Addresses review on #1232:
- Gate the remote-host probe to a foreground host
  (RemoteHostedSystemModalPolicy.isEligibleHostState); background/unknown hosts
  fail closed instead of substituting an unrelated action tree.
- Wrap the alert-resolution fallback query in safeElementsQuery so a dismissed
  remote host raising kAXErrorServerNotFound is absorbed.
- Extract routing/gating into RemoteHostedSystemModalPolicy and add
  simulator-free unit tests under AGENT_DEVICE_RUNNER_UNIT_TESTS.

* refactor(ios): centralize blocking system modal resolution

* fix(ios): bound alert dismissal rechecks

* feat(ios): enable alerts on physical devices

* fix(ios): bound alert system modal resolution

* test(ios): add AccessorySetupKit picker fixture

* fix(ios): validate remote-hosted system modal interactions

* chore: keep pnpm checks non-interactive

* fix(ios): share alert command deadline

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-14 17:19:17 +02:00
Michał Pierzchała 392dc1cded refactor: rename rotate command to orientation (rotate kept as deprecated alias) (#1252)
* refactor: rename rotate command to orientation, keep rotate as a deprecated alias

The top-level `rotate` command (device orientation: portrait/landscape) shared
a name with the `gesture rotate` two-finger rotation gesture. Rename the
orientation command to `orientation` and keep `rotate` working as a minimal,
silent CLI alias (same mechanism as `tap`->`press`) for a few versions.

The rename is applied across every layer:
- command-descriptor registry `name`, daemon dispatch handler, and the typed
  system facet (metadata/cliReader/daemonWriter/schema/output formatter)
- navigation projection + `CommandResultMap` (`OrientationCommandResult`,
  `action: 'orientation'`), client types (`OrientationCommandOptions`), and the
  runtime family (`device.system.orientation`)
- interactor + backend methods -> `setOrientation` (matching the backend's
  `setKeyboard`/`setClipboard` verb convention); Android helper
  `rotateAndroid` -> `setAndroidOrientation`
- Apple/cloud-webdriver capability keys and plugin gate
- user-facing docs (commands.md, client-api.md)

Client SDK method is `orientation` (client convention = camelCase of the
command name, matching `back`/`home`/`appSwitcher`); execution layers use the
imperative `setOrientation`.

Deliberately unchanged:
- the Swift runner wire protocol keeps `command: 'rotate'` — the runner has its
  own command namespace with no gesture collision, so renaming it would only
  risk CLI<->installed-runner version skew on physical devices
- the `DeviceRotation` value type / `parseDeviceRotation` (names the orientation
  values, no collision)

Note: `client.command.rotate` / `device.system.rotate` and the `RotateCommand*`
exported types are removed (the alias only rewrites CLI tokens); SDK consumers
must use `orientation`. The JSON `action` value changes `rotate` -> `orientation`.

* style: wrap long lines to satisfy oxfmt (orientation rename tests)

* fix: add compatibility layer for the rotate->orientation rename

Addresses review blockers on the CLI-only alias: `rotate` previously
resolved only in CLI token parsing, so command-data/RPC paths that carry
the wire command directly failed descriptor validation, and the removed
typed SDK surface broke shipped consumers.

Central command-alias boundary (was CLI-only):
- Promote `cli-command-aliases.ts` to `command-aliases.ts` as the single
  alias source, applied at each command-name ingress that bypasses the CLI
  parser: the daemon request boundary (`handleRequest`, covering replay and
  older remote clients) and the batch step readers (CLI `batch-steps.ts` and
  daemon `batch-policy.ts`). No hand-synced command tables.

Retain deprecated typed SDK surface (shipped v0.18/v0.19):
- `RotateCommandOptions` / `RotateCommandResult` type aliases (legacy
  `action: 'rotate'` contract) and `SystemRotate*` runtime types.
- `client.command.rotate` and `device.system.rotate` deprecated wrappers
  that delegate to `orientation` and restore the legacy response
  (`action: 'rotate'` / `kind: 'systemRotated'`).

ADR 0014: rename `rotate` -> `orientation` in the invalidation guidance
(lines 229, 237) so the accepted architecture doc matches the command name.

Tests: daemon-boundary rewrite, CLI+daemon batch alias resolution, and the
deprecated client/runtime wrappers preserving the legacy contract.

Live emulator evidence (emulator-5554):
- `orientation landscape-left` -> user_rotation=1
- `rotate portrait` (CLI alias) -> user_rotation=0
- batch step `{command:'rotate'}` (no CLI parser) -> user_rotation=1

* fix: preserve orientation rename compatibility

* test: stabilize orientation compatibility formatting

* style: format MCP compatibility test

* revert: drop cross-surface rotate compatibility, keep the lean rename

The rotate->orientation change is a bug fix (name collision with the
`gesture rotate` two-finger gesture), not a compatibility feature. The
cross-surface command-data compatibility added disproportionate weight
(~480 B, dominated by the alias module inlined into the batch bundle) for a
command that was only canonical for two minor versions, so shipped batch/
replay/MCP data carrying `rotate` is a rare, documentable break.

Removed:
- daemon request-boundary command normalization (`request-router.ts`)
- batch step alias resolution (`batch-policy.ts`, `cli/batch-steps.ts`)
- MCP tool-runner alias/legacy-result handling (`mcp/command-tools.ts`)
- the `command-aliases.ts` module rename and cross-surface machinery
  (reverted to `cli-command-aliases.ts`)
- the cross-surface tests

Kept (cheap, high value — prevents build breaks for typed consumers):
- CLI `rotate` alias (one line, same mechanism as `tap`/`launch`)
- deprecated `RotateCommand*` / `SystemRotate*` type aliases and the
  `client.command.rotate` / `device.system.rotate` wrappers that delegate to
  `orientation` and restore the legacy response contract

Net bundle vs main is now +473 B (was +952 B), almost all the kept SDK
wrappers plus the unavoidable longer command name.
2026-07-14 17:17:35 +02:00
Michał Pierzchała 66910f1c75 fix: remove Android ADB swipe fallbacks (#1243)
* fix: remove Android gesture swipe fallback

* fix: tighten Android gesture review follow-up

* fix: route Android touch actions through gesture helper

* test: isolate Android touch provider fixture

* test: drop Android swipe fallback assertions

* test: provide semantic Android touch in provider scenarios

* refactor: drop redundant Android touch planning code

* fix: require viewport for Android touch providers

* refactor: extract Android touch executor

* docs: clarify Android planned touch seam

* fix: complete Android gesture failure handling

* refactor: tighten Android gesture review fixes

* perf: avoid unnecessary Android viewport probes

* fix: remove unused Android helper cache export

* fix: close Android gesture contract gaps

* test: cover max Android helper gesture timeout
2026-07-13 20:12:28 +02:00
Michał Pierzchała 59a75d25ae fix: reject stale iOS refs after navigation (#1241)
* fix: validate stale iOS refs before touch

* fix: preserve stale ref presentation mode

* fix: reject stale iOS mutation refs
2026-07-13 18:03:51 +02:00
devin-ai-integration[bot] ea3813d3b3 fix(daemon): isolate disconnect cancellation and resource teardown (#1225)
* fix(daemon): isolate disconnect cancellation and resource teardown

Cancel HTTP requests that lose their client before response headers, and
scope disconnect cancellation to the affected request/device/session instead
of a global Apple runner abort. Make session resource teardown failure-isolated
so one rejected step no longer skips later cleanup, while preserving lease
release and session deletion.

Closes #1220

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(daemon,runner): request-scoped prep cancellation and platform-close error preservation

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(daemon): split session-close teardown to satisfy complexity gate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(daemon,runner): require pre-close runner stop and add integration prep-cancellation coverage

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(apple): preserve request cancellation during runner build

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(daemon): close request cancellation isolation gaps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(request): keep cancellation cleanup owned

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-13 16:50:45 +02:00
Matt Van Horn b8fa83c53d fix(record): validate finalized iOS device MP4 before reporting record stop success (#1238)
* fix(record): validate finalized iOS device MP4 before reporting record stop success

Physical-iOS `record stop` (runner AVAssetWriter backend) could return a
successful `screen-recording` artifact for an MP4 that was never finalized
(no `moov` atom), because it was the only native recording backend that did
not validate the copied file before reporting success.

Mirror the simulator and Android paths: after the devicectl copy succeeds,
run `deps.waitForStableFile` + `deps.isPlayableVideo` on the output and return
a `COMMAND_FAILED` response (no artifact) when the file is not a playable
video. Also capture the runner-side stop result (`runnerStopOk`) that
`stopRunnerRecordingBestEffort` previously swallowed, fold it into the failure
message, and emit a `record_stop_ios_invalid_video` diagnostic.

Closes #1229

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1ZTEJFKv3BAW36VmBwe2v

* test(record): cover iOS video validation through provider flow

* fix(record): surface iOS runner stop failures

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-13 16:18:06 +02:00
Michał Pierzchała f474f0784e feat: unify gesture planning and multi-touch execution (#1212)
* feat: unify gesture planning and multi-touch execution

* fix: correct unified gesture helper behavior

* refactor: tighten unified gesture architecture

* fix: preserve gesture routing contracts

* test: account for fresh gesture viewport

* refactor: remove retired gesture series

* fix: preserve example app navigation targets

* test: reconcile unified gestures with helper ownership

* docs: update Android helper gesture protocol

* fix: refresh Maestro percentage swipe frames

* refactor: remove stale Maestro frame cache

* fix: harden unified gesture execution

* fix: model gesture viewport in providers

* refactor: remove legacy gesture paths

* fix: remove unused swipe preset parser

* refactor: tighten unified gesture boundaries

* fix: close gesture review gaps

* fix: preserve gesture compatibility contracts

* fix: preserve multi-touch recording semantics

* fix: refresh Apple runner state after app relaunch

* test: lock Apple fling fallback route

* fix: close Apple runner review gaps

* refactor: tighten unified gesture seams

* refactor: consolidate gesture planning policy

* fix: preserve swipe response compatibility

* fix: keep gesture lab aligned with replay coordinates
2026-07-13 13:16:38 +02:00
Michał Pierzchała c23d951a58 fix: preserve Maestro coordinate swipes on Android (#1207) 2026-07-11 19:44:51 +02:00
Michał Pierzchała e2bfed5f9f feat(replay): ADR 0012 migration steps 5+6 — resume + --update retirement (#1211)
* feat(replay): ADR 0012 migration steps 5+6 — resume + --update retirement

Step 5 (decision 4, resume): replay --from <n> --plan-digest <sha256>
resumes at a 1-based plan step, skipping 1..n-1 without executing them.
Every divergence report now carries a real resume object (allowed, from,
planDigest, reason?) computed by a preflight that rejects INVALID_ARGS
before any action when: the plan digest no longer matches the current
script (edits/includes/platform-conditioned expansion), --from is out of
range, a skipped step can produce outputEnv values, or the skipped range
or resume target is runtime control flow (retry/runFlow.when — these are
single plan entries, never individually addressable). `test` rejects
--from/--plan-digest both at the CLI-schema layer and at the daemon
dispatch layer (the original command name is only visible before test
rewrites its nested request to `command: 'replay'`).

New modules: src/replay/plan-digest.ts (canonical SHA-256 plan digest)
and src/daemon/handlers/session-replay-resume.ts (preflight + the
report's resume object), kept out of src/replay/ to avoid a
replay<->compat import cycle.

Step 6 (decision 1, retirement): --update/-u no longer rewrites .ad
files. The ADR mandates a no-op, not an error or flag removal: --update
now runs identically to a plain replay and returns the same bounded
suggestions every divergence already carries. Removed: healReplayAction's
retry-and-rewrite arm and its exclusive helpers (collectReplaySelectorCandidates
stays — decision 1's suggestions still use it), the write call from the
runtime loop, and the env/${VAR}-interpolation/compat-flow refusal guards
that existed only to protect that rewrite. writeReplayScript itself keeps
its own round-trip tests but is otherwise unused now; deleted after the
production-exports gate flagged it as dead.

Docs: cli-help.ts workflow topic + --update/--from flag help, AGENTS.md
selector pipeline note, maestro-compat-debt-map.md, website replay-e2e.md
and commands.md updated for the retired rewrite and the new resume loop.

* fix(ci): classify resume flags + provider-scenario resume coverage

The Integration Tests job's architecture-progress gate
(test:integration:progress:check) requires every public CLI flag to be
classified; --from/--plan-digest (replayFrom/replayPlanDigest) were
unclassified. Classify them as device-observable workflow flags and add
real provider-backed coverage to the Android lifecycle scenario: a full
replay diverges on a missing selector, the report's resume object is
asserted (allowed/from/planDigest), and resuming at the next index
replays only the tail. Also refresh the stale replayUpdate reason
("selector-healing replay update" -> the retired no-op).

* fix: bind replay resume digest to execution plan

* test: align replay runtime module topology

* fix: clear replay CI regressions

* docs: clarify replay repair and resume paths

* docs: clarify replay resume step semantics

* docs(replay): note that ${VAR} values stay out of the plan digest (ADR 0012 + workflow help)

Settled decision from the PR #1211 re-review (maintainer-approved): interpolated
${VAR}/--env/AD_VAR_* VALUES are deliberately NOT part of the resume plan digest.
Substitution happens after the digest is computed over the still-unsubstituted
${VAR} text, so re-running the same script with different variable values keeps
the same digest and stays resumable — supplying the right values on resume is the
caller's responsibility. The digest still binds the script/includes, the effective
--platform/--target, and per-action runtime hints + target-v1 identity. Documented
in ADR 0012 decision 4 and the `help workflow` resume topic.

* docs: clarify replay digest interpolation
2026-07-11 15:06:49 +02:00
Michał Pierzchała 8157b37efd fix: repoint tests to relocated request-progress/cancel modules (#1215)
main was red: this test still imported src/daemon/request-progress.ts
and src/daemon/request-cancel.ts, which no longer exist after the
relocation to src/request/progress.ts and src/request/cancel.ts.
Import-only fix, no behavior change.
2026-07-11 15:06:09 +02:00
Chris Lott 7571b226a3 fix(macos): allow XCTest teardown before runner kill (#1206)
* fix(macos): allow XCTest teardown before runner kill

* fix(macos): abort runner on HTTP disconnect
2026-07-11 12:53:09 +02:00
devin-ai-integration[bot] d3adea4002 Project navigation contracts and add a network digest (#1208)
* test: record contract and digest spike selection

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: project navigation client and MCP contracts from executable definitions

Collapse the three independent per-command projection declarations (facet
clientMethod, public client method signature, MCP output schema) for the typed
system navigation subset (home, back, rotate, app-switcher, tv-remote) onto a
single colocated projection in src/contracts/navigation.ts. The family builder,
public client type, and MCP schema map now derive from those five projections.

Refs #1185

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: add opt-in network response digest

Preserve every network entry and top-level recovery/actionability signal while
dropping only verbose per-entry header, body, and raw-log fields at digest
response level. Record deterministic output-economy baselines and parity tests.

Refs #1186

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-11 10:42:15 +02:00
devin-ai-integration[bot] 0a8ea3a57b refactor: consolidate architecture ownership and client results (#1210)
* refactor: consolidate architecture ownership and client results

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: keep selector parse chunk grouping current

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: update moved architecture breadcrumbs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: enforce moved selector architecture

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: keep selector guarantee ownership current

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: update selector ownership references

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-11 09:40:24 +02:00
Michał Pierzchała c93dcdbc90 feat: disclose selector resolution in interaction responses (#1193)
* feat: disclose selector resolution in interaction responses

Implements ADR-0012 migration step 1 (decision 2). Adds an additive
`resolution` field to press/click/fill/longpress responses: runtime-selector
carries the full pre-action diagnostic shape (unique or disambiguated with
matchCount/winnerDiagnostic/tiebreak/bounded alternatives), runtime-ref and
native-ref carry the exact ref-provenance shape, direct-ios-selector carries
the explicit not-observed marker, and coordinate/maestro-non-hittable-fallback
stay inapplicable (no field). The comparator in selectors-resolve.ts now
records which criterion (visible/deepest/smallest-area) decided each
disambiguation without changing resolveSelectorChain's winner.

Extends the ADR-0011 guarantee matrix with the resolutionDisclosure guarantee
across all six dispatch paths, wires the shared response builder and MCP
output schema, adds digest-level trimming (drops alternatives, keeps the
verdict/counts), and proves via contract tests that resolution diagnostics
are never ref-issued or MCP-pinned and cannot be reused as @ref targets.

* fix: address resolution disclosure review findings

* refactor: make resolution-disclosure choices self-evident

Replace the direct-iOS/maestro message-sniffing (and its justification
paragraph) with an explicit maestroFallback flag passed from the dispatch
site that already owns the path decision, and shrink every why-this-is-OK
paragraph to one-line constraint statements per the maintainer directive.

* fix: usage-based maestro fallback disclosure + spec label-fallback

Blocker 1: the runner-payload source now carries maestroFallbackUsed derived
from the runner's actual execution outcome (the usedNonHittableFallback
message bit RunnerTests+CommandExecution.swift reports, the same signal
directIosSelectorFallbackDetails already keys on) instead of the permission
flag. A fallback-allowed dispatch that hit its element normally discloses
direct-ios/not-observed; only an actually-executed coordinate fallback is the
inapplicable maestro cell. Contract tests cover both sides.

Blocker 2: ADR-0012 decision 2 now defines the ref/label-fallback disclosure
(runtime-ref trailing-label recovery via tryResolveRefNode's fallbackLabel;
native-ref stays exact because the backend receives only the ref handle),
amends the matrix-cell enumeration, layer-3 coverage list, and validation
bullet, and the runtime-ref contract suite proves the label-fallback shape.

* fix: honest runtime-ref registry cells for label recovery

The disambiguation cell no longer claims refs identify exactly one node by
construction — trailing-label recovery is a first-match lookup without the
ranking, now an intentional waiver whose outcome the label-fallback
disclosure surfaces per-response. resolutionDisclosure.via points at
tryResolveRefNode (now exported), the resolver producing both exact and
label-fallback, with direct unit coverage of both outcomes.

* docs: correct native-ref exactness rationale and tiebreak doc

Native-ref forwards fallbackLabel to the backend; exact is justified by
non-observability of any backend-side label recovery, not by non-forwarding.
The tiebreak doc now states the derived winner-vs-runner-up decisive margin.

* fix: disclose Maestro fill fallback usage
2026-07-11 09:09:44 +02:00
devin-ai-integration[bot] 952bc3704a refactor: keep command and daemon-route owner-file claims tooling-only (#1178) (#1192)
* refactor(command-descriptor): keep owner-file claims tooling-only

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(daemon): keep daemon-route owner-file claims tooling-only

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(daemon): guard against re-adding owner-file paths to the production route chain

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(command-descriptor): derive owner-file projection from colocated RAW_COMMAND_DESCRIPTORS

- Keep ownerFiles on each RAW_COMMAND_DESCRIPTORS entry as the source of truth.
- Add tooling-only __OWNER_FILES__ build flag so production bundles omit the
  ownerFiles properties entirely.
- Derive COMMAND_OWNER_FILES from RAW_COMMAND_DESCRIPTORS instead of a
  hand-maintained parallel table.
- Guard command-explain tests against leaking ownerFiles into production
  descriptor objects.
- Enable treeshake.propertyReadSideEffects: false in tsdown to help drop the
  dead ownerFiles branch from production bundles.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: apply oxfmt formatting

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(build): guard tooling metadata exclusion

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(command-descriptor): drop global treeshake option and add bundle guard

- Remove treeshake.propertyReadSideEffects from tsdown.config.ts; the
  __OWNER_FILES__ define + conditional spread already keeps owner files out
  of the bundle, so the global DCE lever is unnecessary and scope-creeping.
- Add a comment on the __OWNER_FILES__ global declaration explaining the
  deliberate type-versus-runtime mismatch.
- Add test/output-economy/owner-files-no-leak.test.ts to build dist and
  assert that no command or daemon-route owner-file path appears in the
  emitted JS.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(build): remove owner metadata property reads

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(command-descriptor): enforce owner claim totality

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-11 07:46:00 +02:00
Michał Pierzchała d4146c7f1b feat: add Android test IME helper for deterministic text entry (#1198) (#1201)
* feat: add Android test IME helper for deterministic text entry (#1198)

Ships a headless InputMethodService (android-ime-helper) as a third Android
helper APK, replacing the visible system keyboard during automated sessions.
Renders zero accessibility nodes and accepts Unicode/CJK/emoji text over a
base64-encoded broadcast channel, fixing both the settle-diff IME-chrome
flood and the ASCII-only adb-shell text entry limit in one structural fix.

- android-ime-helper/: InputMethodService + build/package scripts on the
  existing helper-APK toolchain (javac+d8+aapt2+zipalign+apksigner).
- src/platforms/android/ime-helper.ts, ime-lifecycle.ts: install/version
  lifecycle (shared with the other two helpers via the new
  helper-package-install.ts), activation on session open, and on-device
  restore-hygiene (previous IME persisted to a device settings key so any
  daemon/state-dir can recover it; restored on close, daemon teardown, and
  daemon startup for orphans left by a crashed run).
- input-actions.ts: fill/type route through the helper's broadcast channel
  when active, unicode-safe; unchanged ASCII-shell fallback otherwise.
- doctor: new android-test-ime check flags a stuck helper IME with a
  copy-pasteable `adb shell ime set` remediation command.
- Gating: default-on for emulators, opt-in via `open --test-ime` on real
  devices.
- Dead-weight: rewrote the manual ADBKeyBoard workaround doc, dropped the
  now-provably-live skillgym non-ASCII eval case, updated the ASCII
  fallback's error message to point at the helper instead of dead-ending.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): permission-gate the IME receiver, fix CI, add opt-out

Addresses the independent review's blockers and should-fixes.

SECURITY (blocker 1): the text-injection receiver was RECEIVER_EXPORTED with
no gate — any co-installed app could inject text into the focused field while
the test IME was active. Fixed by requiring the WRITE_SECURE_SETTINGS sender
permission on the (in-process, dynamically-registered) receiver: adb shell
holds it, third-party apps cannot. The reviewer's suggested exported=false +
explicit-component approach was tried first but empirically breaks delivery on
API 36 (adb shell cannot reach a non-exported receiver there) — documented in
the helper README. Live-verified: a purpose-built rogue APK's broadcasts
(implicit and package-scoped, no permission) are silently dropped, field
unchanged; adb shell's bare broadcast still injects. Added
ime-helper-security.test.ts asserting the permission gate and that no
permissionless exported registration returns.

CI (blocker 2): (a) added `testIme` to integration-progress-model flag buckets
(Integration Tests was red on the unclassified flag). (b) mocked
resolveAndroidImeHelperArtifact in session-doctor-android / ime-lifecycle /
input-actions-test-ime tests so they no longer depend on android-ime-helper/dist
existing on disk (Coverage was red on a fresh checkout); verified by running
them with dist removed.

Should-fixes: added `--no-test-ime` to opt out on emulators (tri-state gating,
parser-tested); PR body's "byte-identical" claim corrected to size/CRC-match.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(#1201): pin the API-36 exported-receiver constraint in a comment

The RECEIVER_EXPORTED flag cannot express why it must stay exported. Add a
one-line note so a future hardening pass doesn't switch to RECEIVER_NOT_EXPORTED
and silently break the CLI (adb shell can't deliver explicit broadcasts to
non-exported components on API 36+; WRITE_SECURE_SETTINGS is the actual gate).

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): harden IME restore lifecycle (blockers 1 & 2)

Blocker 1 — a failed restore no longer deletes the recovery value. restore now
reads back default_input_method after `ime set` and only clears the persisted
previous-IME record on a confirmed-successful restore; a failed set keeps the
value so a later retry / startup recovery / doctor remediation can still
un-strand the user off the helper IME.

Blocker 2 — startup orphan-recovery no longer overwrites/races user state.
It only restores when the device's CURRENT default IME is still our helper
(so a user who legitimately switched away is left alone), and skips any device
a live session in this process owns (the fire-and-forget startup vs. concurrent
`open` race — activate now marks the device active BEFORE the `ime set`, so any
recovery pass that could observe the helper active also observes the flag and
skips). Never persists the helper itself as the previous IME. activate also
verifies its own switch via read-back.

Exported ANDROID_IME_HELPER_SERVICE_COMPONENT so restore compares the active IME
without reading the packaged artifact from disk. Tests: failed-restore keeps the
value (+ later recovery succeeds), startup no-op when current != helper, startup
skips a live-owned device.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(#1201): delete unused ACTION_ENTER path, baseline test-only export seams

Rebased onto main (#1202 production-unused-exports gate). Two follow-ups:

- Deleted the unused ACTION_ENTER broadcast end-to-end (TS sendAndroidImeHelperEnter
  + its test, Java handler, README): nothing routes through it — `keyboard enter`
  uses the keyevent ENTER path — so the new production-exports gate flagged it as
  dead production code. Removed rather than grandfathered.
- Added the three legitimate test-only seams (resetAndroidImeHelperInstallCache,
  resetAndroidTestImeActivationCacheForTests, setAndroidTestImeActiveForTests) to
  fallow-baselines/production-unused-exports.json, matching how the sibling helper
  reset functions (resetAndroidMultiTouchHelperInstallCache, ...) are already
  grandfathered there.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201): stop daemon-startup adb spawn on non-Android hosts (macOS Smoke)

Root cause of the red macOS Smoke shard (proven, not hand-waved): the
fire-and-forget restoreOrphanedAndroidTestImeOnDaemonStartup ran `adb devices`
at EVERY daemon startup, on every platform. GitHub macOS runners ship the
Android SDK, so this cold-started the adb server mid-replay and destabilized the
macOS System Settings replay timing — the failed job's cleanup shows
"Terminate orphan process: pid (N) (adb)"; main's green runs spawn no adb.

Fix: gate the startup orphan scan behind a host-side marker written in the
daemon state dir when a session activates the test IME (mirrors the
managed-web-browser orphan-cleanup `installed` gate). A host that never uses the
Android test IME — the macOS CI runner included — never writes the marker and so
never spawns adb at startup. The marker is cleared once nothing is left stuck.

Adds SessionStore.resolveStateDir(); tests: startup recovery does not scan adb
when no marker exists (+ marker cleared after a clean scan).

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(#1201): suppress fallow class-member false-positive on state-dir accessor

CI's Fallow audit flags SessionStore.resolveDaemonStateDir as an unused class
member, but it is called via sessionStore.resolveDaemonStateDir() in
session-open.ts — fallow's class-member tracer just doesn't resolve a method
call sited inside a call argument. Renamed for clarity (avoids the collision
with config.ts's free resolveStateDir) and added the localized
fallow-ignore-next-line unused-class-member suppression.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): durable persist before switch + device-scoped recovery markers

Addresses devin-ai-integration's two P1 restore-safety blockers on 19cbce79d.

P1.1 — durably persist the restore target BEFORE the global IME switch.
writePersistedPreviousIme now checks the `settings put` exit code AND reads the
value back, returning a boolean. activate persists first and, if it cannot be
persisted, fails open to the existing input path WITHOUT switching — a rejected
`settings put` can no longer strand the user on the helper with no restore
target. Regression test added.

P1.2 — close the marker crash/offline blind spot. Recovery intent is now
recorded per device, BEFORE the switch (ordering: durable record -> marker ->
ime set), eliminating the post-switch/pre-marker crash window. Markers are
device-scoped and each is retained until that device is actually observed clean:
an offline/disconnected-but-stuck device keeps its marker and is recovered on
reconnect instead of being cleared because the current `adb devices` scan saw no
set-failed. Close-time restore clears only that device's marker (stateDir plumbed
through teardown/close). Tests cover the persist-failure, post-switch/pre-marker
crash, offline-then-reconnect, live-session-owned, and user-switched-away cases.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 19:38:37 +02:00
Michał Pierzchała 58995bf51b feat: parse and preserve .ad target-v1 evidence (ADR 0012 migration step 3) (#1196)
* feat: parse and preserve .ad target-v1 evidence (ADR 0012 migration step 3)

Recording now emits a `# agent-device:target-v1 {...}` comment immediately
before every click/press/longpress/fill action that resolves through the
tree (ref or selector), carrying the identity/ancestry/sibling/scrollRegion/
viewportOrder tuple from decision 3's record-time write algorithm plus a
record-time self-check (verified/unverifiable). The parser accepts known
fields in any order, NFC-normalizes, rejects malformed/oversized annotations
with INVALID_ARGS, binds an annotation only to the immediately-next action
line, and treats unknown future target-vN comments as ordinary comments.
writeReplayScript's read-then-rewrite (heal) path preserves v1 annotations
in canonical form. Nothing consumes the parsed evidence yet beyond
preservation — replay-time enforcement is a later migration step.

The identity tuple's own node/tree only ever lived on the internal
visualization/session-history payload (never the public response); it is
converted to the compact target-v1 evidence and stripped before anything is
persisted or returned.

* fix: bound candidate identity before self-check comparison

The identity-set scan in computeTargetEvidence compared an untruncated
candidate identity against the 256-byte-bounded recorded identity, so a
node whose own id/label exceeds the field cap could fail to match itself,
corrupting the record-time self-check. Compare through the same bounding
on both sides instead.

* fix: address PR #1196 review — contract boundary, formatting, path-6 reasons, worst-case sizing

- native-ref contract test: deliberately updated to assert the new ADR 0012
  boundary — the preflight's node/preActionNodes ride the INTERNAL runtime
  result and visualization payload only; the public responseData never
  carries node/preActionNodes/targetEvidence (asserted directly against
  buildInteractionResponseData).
- oxfmt formatting on all touched files.
- classifyTargetBindingMatch path 6 now distinguishes decision 3's two
  spec-distinct outcomes: a signal isolating a member that differs from the
  winner ('signal-isolated-wrong', the paths-4/5 comparison class, future
  identity-mismatch) vs true fall-through ('no-signal-isolation', future
  identity-unverifiable), so step 4 can consume the reasons directly.
- writer reduction loop sizes every candidate against the worst-case
  verification value ("unverifiable" is 4 bytes longer than "verified"), so
  a fail-closed self-check downgrade can never push an accepted payload over
  the 4 KiB cap; pinned by a 1-byte-granularity sweep across the boundary
  that fails against the old placeholder-sized check.

* fix: reject missing role keys in target-v1 annotations, correct data-flow comment

Second-review nits: the writer emits `role` unconditionally (top level,
ancestry entries, scrollRegion) — possibly as the empty string for typeless
nodes, which stays accepted — so a MISSING role key can only come from a
hand-edited/adversarial annotation and is now rejected with INVALID_ARGS
instead of silently parsing as an implicit empty role, which step-4
enforcement could otherwise match against anonymous wrapper nodes. Also
corrects the interaction-touch-response comment that overstated where the
raw node/tree flows (finalizeTouchInteraction strips it before both session
history and touch overlay telemetry).

* fix: record iOS selector target evidence

* refactor: make the record-time evidence channel structural, drop argument-comments

Maintainer directive: comments that argue a workaround is acceptable mark
code to redesign. Applied to the whole PR diff:

- The record-time node/tree now travel on a typed `recordedTarget` side
  channel of InteractionResponsePayloads instead of being smuggled through
  the visualization Record and stripped later. The construction site routes
  them there exclusively, finalizeTouchInteraction consumes the channel
  directly, and extractTargetEvidenceForRecording (the strip helper and its
  justifying doc) is deleted — the public/internal split is now enforced by
  the type shape, so the contract test asserts it in two lines instead of a
  paragraph.
- findNearestScrollableContainer/findNearestAncestor made generic over the
  node type, removing a cast plus its safety-argument comment.
- computeLocalIdentity/boundedLocalIdentity collapsed into one always-capped
  identity reader — the raw/bounded split was the root of the earlier
  self-match bug and existed only to be explained.
- parseReplayScriptDetailed's rejectUnbound takes the pending annotation as
  a parameter, removing a non-null assertion and its comment.
- Remaining paragraph-length argument-comments trimmed to one-line
  constraint statements (module docs, sizing/floor comments, regex/role
  rationales, test comments); spec-mapping docs stay.

* chore: oxfmt the selector-evidence test files

* fix: record get target evidence, fail closed on broken parent linkage

Maintainer blockers on ADR 0012 step 3:

- get text/attrs now records target-v1 evidence: GetCommandResult carries
  the resolution tree (preActionNodes, internal — neither the recorded
  result nor the public payload copies it), recordIfSession gains the typed
  recordedTarget channel, and dispatchGetViaRuntime threads the capture
  through. The direct-iOS get query is gated during recording so the
  snapshot path supplies the evidence tree, mirroring the tap/fill gating.
  find/is stay uncovered: their results are match-set shaped (found flags,
  predicate booleans over possibly-many matches), not a single resolved
  winner — noted in the PR.
- buildAncestryChain now reports a broken parent walk (dangling parentIndex
  or cycle) instead of silently producing a root-like chain; the writer
  fails the annotation closed to 'unverifiable' per decision 3's
  capture-anomaly rule, and broken candidates cannot prove an ancestry
  prefix. Regression tests for both anomaly shapes.
- New ADR 0012 recording tests live in focused files
  (interaction-target-evidence.test.ts) instead of growing the
  interaction.test.ts aggregation; the earlier additions moved out.

* refactor: extract direct-iOS get selector guards below the complexity threshold
2026-07-10 18:43:17 +02:00
Michał Pierzchała f53d572f87 fix: align Maestro swipe semantics across platforms (#1179)
* fix: preserve explicit Android Maestro swipe lanes

* fix: align Maestro swipe semantics across platforms

* fix: avoid replaying iOS Maestro gestures

* refactor: make swipe coordinate policies explicit
2026-07-10 16:41:54 +02:00
devin-ai-integration[bot] db492cbaed test(output-economy): add routine-workflow output-behavior oracle (#1190)
* test(output-economy): add routine-workflow output-behavior oracle

Add a deterministic routine-workflow measurement (#1180) that pairs
response bytes with follow-up behavior: fallback-observation count,
retry count, and whether an actionable failure preserves the session.
Refs chain across one recorded checkout session and counts derive from
the real formatters, so dropping settled-diff refs, the unchanged-
interactive tail, or a recovery handle fails the suite. Adds a matching
non-gating help-conformance next-command case. Response defaults
unchanged.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(output-economy): make routine-workflow ref-surfacing depend on rendered output

Address review on #1190:
- Drop the raw mutation-confirm/failure MCP data samples that leaked e4/e5
  and mislabeled the surface; e4/e5 now surface only from the rendered CLI
  settled-diff, so dropping added refs genuinely raises the fallback count.
- Track the failure once as its projection-invariant normalized payload
  (workflow.failure.shared.json) instead of duplicate cli/mcp raw copies.
- Reuse the shared REF_TOKEN_PATTERN from economy-metrics.ts.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(output-economy): reuse shared fixtures in routine-workflow oracle

Address review finding #2 on #1190: chain the workflow onto the shared
per-surface fixtures instead of copy-pasting them.
- orient/recheck reuse SNAPSHOT_RESULT + SNAPSHOT_DAEMON_RESULT; the
  first mutation reuses SETTLE_ADDED_REF_RESULT, so session identity and
  ref generations come from ./fixtures.ts and the two suites cannot drift.
- Only the genuinely workflow-specific pieces remain local: the unchanged
  recheck, a tail retargeted onto a settled-diff ref (SETTLE_TAIL_RESULT
  taps an unsurfaced @e6 and can't chain), the in-session timeout failure,
  and its recovered retry. routine-workflow.ts drops ~110 LOC.
- Rendered-output ref guard preserved: @e5 (settled diff) and @e7 (tail)
  surface only from formatter output; recovery semantics unchanged.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-10 15:27:24 +02:00
devin-ai-integration[bot] ae74c51abd chore: add agent-efficiency regression guards (#1174)
* chore: ratchet architecture dependency graph

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: ratchet agent-facing output economy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: derive command navigation explanations

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: keep efficiency checks fallow-clean

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(layering): enforce back-edge ceiling monotonicity and cover root src files

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(layering): pin back-edge-ceiling ratchet to PR merge-base

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(output-economy): baseline-independent actionability floors, policy-derived error, like-for-like screenshot surfaces

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(explain): resolve true CLI aliases, canonical usage, and derived owners

Surface true CLI aliases from parser normalization (long-press, metrics,
tap, launch, relaunch) distinct from catalog keys, preserving implied-flag
semantics (relaunch => open --relaunch). Extract the canonical single-line
usage builder to src/utils/cli-usage.ts so schemas without usageOverride
include positionals and flags. Replace guessed handler paths with a
completeness-checked daemon-route owner map keyed by the closed
DaemonCommandRoute union, fixing silently-dropped non-kebab routes
(reactNative, recordTrace) and generic dispatch. Add table-driven coverage
for aliases, synthesized usage, split-family/route-variant/dispatch owners,
structured output, and explain:command CLI exit/stdout/stderr.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: enforce exact ratchets and compact command explain

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: colocate command ownership metadata

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: bind daemon owners to production routes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: preserve generic dispatch bundling

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: enforce monotonic output budgets

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-10 11:54:09 +02:00
Michał Pierzchała 983625fc5d feat: fix codex runner, add --override-doc grading, port skillgym quiz cases to help-conformance bench (#1176)
* feat: fix codex runner, add override-doc grading, port skillgym quiz cases

The 2026-07-09 evaluation of scripts/help-conformance-bench.mjs found it
structurally right but broken for the codex runner (two bugs), thin on
coverage (4 cases), sequential, and unable to grade a draft help rewrite
without a rebuild.

- Fix runCodex: (1) codex exec reads stdin until EOF when not attached to
  a TTY, and execFile never closes the child's stdin, so every codex call
  hung until RUN_TIMEOUT_MS with empty output — close stdin right after
  spawn. (2) `-o outFile` writes the same final JSON that codex also
  prints to stdout, so concatenating both produced two back-to-back JSON
  objects that broke every JSON.parse candidate and silently zeroed
  extractCommands() — prefer the clean -o payload, fall back to stdout
  only when it's empty.
- Add `--override-doc <topicId>=<path>` (repeatable): loads a topic's
  text from a file instead of shelling out to
  `node bin/agent-device.mjs help <topic>`, so a draft help rewrite can
  be A/B graded with zero rebuild.
- Port three cases from
  test/skillgym/suites/agent-device-smoke-suite.ts
  (settle-diff-is-observation, sample-output-settled-diff-next-target,
  sample-output-not-settled-needs-observe) as self-contained
  "next-command quiz" cases, generalizing the scorer to support regex
  matchers/forbidden patterns alongside the existing named expectations.
  Fixture output text matches the CURRENT settle rendering in
  src/commands/interaction/output.ts, including the "unchanged
  interactive (N):" tail added by #1167/#1172.
- Parallelize the runner x case matrix with a concurrency cap
  (HELP_BENCH_CONCURRENCY, default 4); results still print in the
  original matrix order.
- Extend test/skillgym/README.md's existing pointer to this bench with
  the new flags.

Validated with real LLM calls (both runners, all 7 cases, 14 calls,
~$0.25 total): 13/14 pass; the one fail (claude-haiku-4-5 on
dogfood-mode) is a genuine model miss (returned an empty command plan
asking for the app name instead of committing to a generic plan), not a
bench bug. `--override-doc` demonstrated live: stripping the dogfood
doc's evidence-command examples regresses codex:gpt-5.4-mini from 3/3 to
2/3 on the same case, showing the flag both loads and changes grading.

* fix: apply live-doc post-processing to --override-doc, fail fast on bad overrides

Review findings on the initial version (all reproduced):

- HIGH: an override for the --help:first30 doc id skipped the live path's
  firstLines(text, 30) cap, so a 49-line draft leaked lines 31-49 into the
  prompt — grading content a live run never shows, on the doc id every case
  uses. loadDoc now splits source (live shell-out vs override file) from
  post-processing, and the post-processing applies to both, so an override
  differs ONLY in where the text comes from.
- MEDIUM: an --override-doc topic id no selected case uses was silently
  ignored (exit 0, real doc graded). Now fails fast listing the valid doc
  ids for the selection.
- LOW: a missing override file threw a raw ENOENT stack trace; expected
  failures now print one clean Error line. Added --help usage text that
  documents last-wins semantics for repeated same-topic overrides and the
  post-processing parity.

Guard tests (scripts/__tests__/help-conformance-bench.test.ts, wired into
the unit-core vitest project by explicit path): a 49-line fixture whose
prompt must keep line 30 and drop line 31, unknown-topic fail-fast with
valid ids listed, clean no-stack error for a missing file, and last-wins
for repeated overrides. All spawn the script in --dry-run with every
required doc overridden, so they need no LLM calls and no built CLI.

Live re-validation: a 33-line override of --help:first30 whose lines
31-33 instruct the model to emit a sentinel command; neither
claude-haiku-4-5 nor codex:gpt-5.4-mini emitted it (both scored 4/4,
matching the live-doc baseline), proving the cap applies end-to-end.
2026-07-10 09:06:58 +02:00
Michał Pierzchała b8ac75893a fix: make settle tail list real actionable targets (#1172)
* fix: make settle tail list real actionable targets

Post-merge benchmark of #1167 (React Navigation prevent-remove flow, iOS
sim, claude-haiku/sonnet) found the unchanged-interactive tail regressing
to chrome-only noise in exactly the cases it was built for:

- buildSettleTailEntries required `hittable === true`, stricter than what
  `snapshot -i` itself shows for the same interactive-only capture. Right
  after a dismiss animation, real buttons commonly report `hittable:
  false`/undefined while application/window containers pass, so the tail
  surfaced two useless chrome lines and dropped the actionable button.
  Fixed by dropping the hittable requirement and excluding structural
  application/window roles instead.
- withoutKeyboardKeys only stripped `Key` nodes; real keyboard chrome
  (shift/Emoji/return/Dictate/Next keyboard) are XCUIElementTypeButton
  nodes and leaked through as fresh added-line refs, which suppressed the
  tail trigger for exactly the post-fill case it exists for. Fixed by
  detecting the whole keyboard subtree structurally (via parentIndex, not
  a locale-fragile label list): descendants collapse out of the diff, and
  the keyboard container's own ref no longer counts as a "meaningful"
  added ref for the trigger decision.

Nobody presses shift via settle diff refs, so collapsing keyboard chrome
does not block a user from explicitly targeting the keyboard itself.

* fix: classify the whole iOS keyboard window as settle chrome

Live-device review of the first Bug B fix found "Next keyboard" and
"Dictate" still leaking into the settled diff as added refs: on a real
iPhone 17 Pro simulator they live in a SIBLING subtree of the [Keyboard]
container (the candidate bar), not under it, so the container-descendant
walk missed them. A raw hierarchy capture shows the software keyboard in
its own dedicated window hosting both the container and the candidate
bar, so the structural rule is now: every node inside a window that has
a [Keyboard] descendant is keyboard chrome. Conservative guard: a window
also hosting an editable text node outside the container (iOS puts
inputAccessoryView composers in the keyboard window) is never
window-classified — those fall back to the container-descendant walk.

The live run also showed the filled field re-labeling itself with its
new value (ancestor wrappers inherit it), which produced added refs that
suppressed the tail even with chrome fixed. The trigger now also ignores
self-echo refs — added lines whose settled node rect contains the action
point — since they re-describe the acted-on element, not a new target.

The fill-keyboard provider fixture is now a trimmed REAL capture from
the benchmark flow (sibling candidate bar, main app window absent from
the interactive settled capture, self-echo relabels) instead of a
hand-built tree that hid the sibling-branch shape.
2026-07-09 21:07:15 +02:00
Michał Pierzchała a3885351c2 fix: stabilize android maestro gestures (#1171)
* fix: stabilize android maestro gestures

* fix: address maestro android gesture review
2026-07-09 19:31:11 +02:00