263 Commits

Author SHA1 Message Date
Michał Pierzchała 2e74b789fd feat: verify device cloud connections (#1564)
* feat: verify device cloud connections

* refactor: unify connect provider adapters

* refactor: separate connect verification facts

* fix: tighten connect provider verification

* fix: use neutral cloud connection wording

* perf: deduplicate local affected checks

* refactor: simplify affected check runner

* refactor: derive connect workflow from verification
2026-08-03 16:47:57 +02:00
Michał Pierzchała 99967c7f01 fix: restrict project config trust (#1565)
* fix: restrict project config trust

* fix: preserve daemon auth transport context

* refactor: simplify project config trust

* fix: restrict project config write sinks
2026-08-03 15:48:45 +02:00
Michał Pierzchała f8617a2db9 fix: read Android get text from target field (#1561) 2026-08-03 12:09:31 +02:00
Michał Pierzchała 480e3883b1 fix(daemon): reject unarmed close --save-script before teardown (#1558)
* fix(daemon): reject unarmed close --save-script before teardown

Live evidence (2026-08-02) showed a plain `open` followed by `close
--save-script` silently published a script: the close request armed
authoring at record time and published moments later in the same
request, folding the never-armed case into the ADR 0016 authoring
lifecycle. The resulting .ad carries selector fallback chains but no
recording-time target-v1 evidence, and nothing told the caller
evidence capture never ran — degraded replay verification with no
signal beats a loud refusal.

`assertTerminalRecordingCloseAllowed` (src/daemon/handlers/session-close.ts)
now rejects an unarmed `close --save-script` with INVALID_ARGS before
any teardown or filesystem work runs, the same seam that already
rejected ABORTED/PUBLISHED terminal recordings. The rejection does not
tear the session down, so a plain `close` retry still completes
cleanly; recovery names `open --save-script` since evidence can only
be captured from action zero. Repair transactions (ADR 0012) are a
disjoint lifecycle and are explicitly unaffected.

This is distinct from #1533 (an already-armed-then-aborted session
whose flag ingress re-enables recordSession and lets a *bare* close
publish); that case remains open.

* fix: review follow-ups for #1558 (help text, test strength, docs)

- Give replay --save-script its own help text instead of the shared
  open/close "arm on open, publish on close" description: replay's flag
  arms an ADR 0012 repair transaction, a disjoint lifecycle. Adds
  CommandSchema.flagDescriptionOverrides so a command can swap a shared
  flag's usageDescription without duplicating the FlagDefinition entry
  (which would have shown --save-script twice in `help replay`). Pinned
  in src/cli/parser/__tests__/cli-help-command-usage.test.ts (open/close
  keep the shared text unchanged; replay gets the new one).

- Strengthen the never-armed close --save-script regression test in
  session-close-shutdown.test.ts: the fixture now carries real
  cleanup-bearing state (an active iOS simulator recording, reusing
  makeIosSimulatorRecordingSession/recordingKillMock) with spies proving
  no teardown hook (recorder kill, runner stop) runs on the rejected
  request, then that a follow-up plain close does tear it down. The
  prior fixture had nothing for teardown to observably touch, so moving
  the guard after stopBestEffortSessionResources would have passed it
  silently. Also fixes a latent test-isolation leak this exposed: an
  earlier test set a persistent mockStopIosRunnerSession rejection
  (vi.clearAllMocks() clears call history, not implementations), which
  would have poisoned any later Apple-platform close test; scoped it to
  mockRejectedValueOnce.

- Point the migration guide (website/docs/docs/migrating-gestures.md) at
  `open --save-script` → interact → `close` instead of the now-rejected
  `open` → interact → `close --save-script`, matching the new guard and
  the corrected help text.

_Generated by [Claude Code](https://claude.ai/code)_
2026-08-03 09:10:40 +02:00
Michał Pierzchała 2c2df031ff feat: keep replay session active on request (#1554)
* feat: keep replay session active on request

* test: cover replay keep-session provider route

* fix: make replay session handoff reliable

* refactor(daemon): extract the replay terminal-lifecycle policy module (#1554 review)

session-replay-runtime.ts was already over the 500-line extract-before-adding-behavior
tripwire before this PR; the keep-session/repair terminal-close decision, its
live-session postcondition, and the dispatched-action count pushed it further past
budget. Move that policy into a focused session-replay-terminal-lifecycle.ts
(isExecutableReplayAction, resolveSuppressedTerminalCloseIndex,
countExecutedReplayActions, requireLiveSessionForKeepSession) so the runtime file
stays orchestration-only, and mirror its PR-added unit tests into
session-replay-terminal-lifecycle.test.ts. Pure extraction: no assertions changed.
2026-08-02 18:35:13 +02:00
Michał Pierzchała e5cebcd8e3 fix(test): tolerate dead session in iOS e2e full-tier cleanup (#1548)
* fix(test): tolerate dead session in iOS e2e cleanup

full:device-lifecycle reboots the simulator, and whether the daemon
session survives that is environment-sensitive: it does on CI but not
locally, so every all-green local full-tier run ended red in cleanup
with all three retries of each step failing ('permission setting
requires an active app in session' / 'No active session').

Two layers:
- finalizeLiveRun re-checks sessionExists instead of short-circuiting
  on sessionOpen, so a session that died mid-run skips cleanup entirely.
- cleanupSession treats SESSION_NOT_FOUND and the appless-session
  INVALID_ARGS failure as already-clean instead of burning retries.

Other cleanup failures still exhaust three attempts and fail loudly.

* fix(test): narrow appless-session cleanup guard to the mic-permission step

Scope sessionAlreadyClean's INVALID_ARGS tolerance to the microphone-
permission reset step and its exact known message instead of matching
any cleanup step whose message contains "requires an active app in
session" — that substring is also thrown by the unrelated location
setting, so the old check could have hidden a real failure there.

Extract the per-step retry policy into an exported retryCleanupStep so
it's unit-testable without spawning the CLI, and add a deterministic
regression (test/integration/ios-simulator-e2e-cleanup.test.ts)
covering: a dead session (SESSION_NOT_FOUND) stops retrying on any
step, the known mic-permission appless response stops retrying, and a
different INVALID_ARGS (wrong step or wrong message) still exhausts
all three retries and fails.

* test(ios-e2e): cover the finalization cleanup-gate decision

Extract the sessionOpen re-check + conditional cleanupSession call out
of finalizeLiveRun into an exported finalizeSessionCleanup(context,
runSessionExists, runCleanupSession) — same behavior, now driven by
injected callbacks instead of the module-level runStep-backed
bindings, so it's unit-testable without spawning the CLI.

Add two deterministic cases: sessionOpen starts true and the final
sessionExists() resolves false -> cleanupSession is never invoked;
sessionOpen true and sessionExists() resolves true -> cleanupSession
runs (the live path). Counterfactual (reverting the recheck to the old
`sessionOpen || sessionExists(...)` form) turns the first case red as
expected.
2026-08-02 11:36:25 +02:00
Karthik Varma 92b22229e6 feat(cloud-webdriver): BrowserStack device-feature capabilities, and fix cloud orientation (#1544)
* feat(cloud-webdriver): support BrowserStack device-feature capabilities

Adds the eight BrowserStack "device feature" session capabilities that had no
representation in agent-device: deviceOrientation, geoLocation, timezone,
language, locale, networkProfile, customNetwork, and resignApp.

These are vendor capabilities, so they are emitted inside `bstack:options`
rather than at the top level. BrowserStack's YAML config lists them unnested
and its SDK relocates them; agent-device talks to the hub directly, so it
nests them itself.

A single spec table drives both the flag reader and the capability builder, so
adding a capability is a table row rather than a branch in each. A structural
test asserts every field owns exactly one row, since a field the table forgets
would parse off the CLI, ride the profile, and then be silently dropped before
the hub ever saw it.

Rejects combinations the provider cannot act on unambiguously: an unknown
orientation is caught at the flag boundary instead of being forwarded to a hub
that accepts and then ignores it, --provider-no-resign-app is refused on
Android, and a named network profile cannot be combined with a custom network
shape.

Also fixes a latent shallow-merge bug in buildBrowserStackCapabilities: a
caller supplying its own `bstack:options` replaced the whole object and
silently dropped the project, build, and session labels. It is now merged
per key.

* fix(cloud-webdriver): rotate via WebDriver orientation endpoints

`setOrientation` on the cloud WebDriver path sent `mobile: rotate`, which is
not a driver command at all. UiAutomator2's own error enumerates its
extensions and `rotate` is absent from the list, so `agent-device orientation`
was a hard failure on every hosted provider.

It also forwarded agent-device's four-way rotation vocabulary verbatim
("landscape-left", "portrait-upside-down"), where the protocol accepts only
uppercase PORTRAIT/LANDSCAPE. Every other platform has a translation layer;
this path was the only one without one.

Now two transports, ordered by backend. `POST /rotation` takes exact four-way
degrees and leads on Android, since it is the only endpoint that can express
upside-down and left-versus-right. `POST /orientation` is two-way and leads on
XCUITest, which rejects `/rotation`. Each falls back to the other, because only
BrowserStack's UiAutomator2 is verified and a provider whose driver disagrees
should degrade rather than hard-fail.

Verified live against BrowserStack App Automate:
  POST /rotation {"x":0,"y":0,"z":0} -> 200 {"value":"ROTATION_0"}

The rotation-to-surface-index mapping moves to contracts/device-rotation.ts and
the existing adb path now reads from it, so the local and hosted mappings
cannot drift apart.

Note this rotates the current display, not persistent device rotation, so an
activity that does not pin its own orientation may still need rotating once it
is in the foreground.

The capability was declared "partial" without the transport existing, and no
test covered setOrientation on the cloud path; only adb and the Apple runner
were covered. Both gaps are now closed.

* fix(cloud-webdriver): narrow orientation fallback and gate provider-owned flags

Addresses review on #1544.

The orientation fallback caught every error, so a timeout, an auth rejection, a
dead session or a provider 5xx on the first transport was swallowed and retried
against the second. When that one also failed the caller got "rejected both
endpoints" with the real cause discarded. Fallback is now keyed on structured
unsupported-endpoint signals only — HTTP 404/405, or a W3C `unknown command` /
`unknown method` code — matching the repo rule of keying on typed details rather
than message text. Everything else rethrows unchanged.

Device-feature capabilities are BrowserStack-owned, but the flags were accepted
by any cloud provider, persisted into the generated profile, and then silently
dropped at session creation. `connect aws-device-farm` now rejects them with a
typed error naming each offending flag, raised before the provider's own
required-argument checks so the caller is told what is unsupported rather than
what else is missing. Ownership is modelled on the capability spec table, so a
new capability inherits the guard without a second list to maintain.

Adds provider-backed orientation scenarios driven through public daemon dispatch
against the fake WebDriver provider: the four-way endpoint on the happy path,
the documented collapse onto the two-way endpoint when the driver does not
implement `/rotation`, and a provider 5xx that must surface without consulting
the second transport. The fake server's route handling became a table in the
process — it had grown to ten branches in one function.

* fix(cloud-webdriver): read W3C error codes before status, enforce ownership at the runtime boundary

Addresses the second review pass on #1544.

The fallback classifier returned on any 404/405 before consulting the W3C error
code, so an HTTP 404 carrying `invalid session id` was masked as a missing route
and retried against the second transport. The structured code now takes
precedence whenever the driver sent one; bare status is consulted only when no
code exists. Two cases pin it: a 404 `invalid session id` and a 405 `timeout`
must both surface rather than fall through.

Provider ownership was enforced only in the CLI profile builder, which the typed
client and hand-authored remote-config profiles bypass entirely — both reach
session preparation without passing through `connect`, so the capabilities were
accepted and then dropped. The check now lives on the capability-ownership
module and runs inside AWS Device Farm's `prepareSession`, with the CLI builder
calling the same helper instead of its own copy. Covered by a scenario that
drives the runtime boundary directly and asserts the rejection happens before
any provider session is created.
2026-08-02 08:17:49 +02:00
Michał Pierzchała b9509fe006 refactor: extract the .ad script codec into packages/ad-script (#1478) (#1536)
* refactor: extract the .ad script codec into packages/ad-script

Moves the mutually-coupled .ad read/write codec (script.ts, script-utils.ts,
script-formatting.ts, open-script.ts) plus the target-v1 annotation SERDE
slice of target-identity.ts into a new private leaf package,
@agent-device/ad-script, exporting only `.`. This is option 1 from the P5
scoping dossier on #1478: the codec is shared by the daemon's session-script
publication writer, the future replay engine, the CLI's `replay export`, and
Maestro's failure-label formatting, so it can no longer live in root src/
once packages/ad-replay lands (R11 forbids a package reaching into root src),
and a second export subpath or writer-half duplication are both ruled out by
existing gates/tests.

target-identity.ts keeps only the record/replay-shared classification core
(classifyTargetBindingMatch, local-identity/ancestry-prefix matching),
importing its shared types from the new package. Every real consumer
(re-derived by grep, not the dossier's list alone) is rewired to
@agent-device/ad-script.

Refs #1478

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

* refactor: trim the ad-script façade to real consumers, lock the one-export boundary

- packages/ad-script/src/index.ts: drop parseReplaySeriesFlags,
  formatTargetAnnotationCommentLine, parseTargetAnnotationCommentLine,
  TargetAnnotationLineParseResult, and TargetRect from the public façade —
  none has a consumer outside the package (re-swept every remaining export
  by grep; everything else kept has at least one real external importer).
  The functions/types stay exported from their declaring internal modules
  for the package's own internal use (script.ts, script-formatting.ts).
- scripts/layering/package-boundaries.test.ts: add the parallel R11
  assertions "the real tree parses, declares, and passes R11" already makes
  for maestro/provider-webdriver/provider-limrun/xml — ad-script exports
  exactly `.`, depends on exactly contracts+kernel, and is declared in root
  package.json — plus ad-script entries in the deep-resolution rejection
  coverage. Verified the lock catches a regression: temporarily added a
  fake `./codec` export to packages/ad-script/package.json and confirmed
  both the export-key-list assertion and the deep-resolution-rejection
  assertion fail; removed the plant and reconfirmed green.

Refs #1478

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

* fix: remove polynomial-redos ambiguity from the target-v1 annotation line regex

CodeQL js/polynomial-redos flagged TARGET_ANNOTATION_LINE_RE
(packages/ad-script/src/internal/target-annotation-serde.ts): the payload
group's `\s+(.*)` let `\s+` and the unconstrained `.*` both match whitespace,
so a run of separator whitespace that ultimately fails to complete the match
has many `\s+`/`.*` splits to backtrack through before concluding failure.

Anchor the payload group on `\S` (the exact complement of `\s`), so the
mandatory `\s+` separator and the payload's first character can never
overlap — the split point becomes unique and no backtracking is possible.

Behavior-preserving: the only caller (parseTargetAnnotationCommentLine)
always matches against an already-.trim()-ed line, whose last character
(whenever the tag matches at all) is never whitespace — so a payload section
`\S.*` would reject (content that is entirely whitespace) can never reach
this regex through the real call path. Verified against the frozen
replay-compat corpus and the full serde/parser test suites, unmodified.

Added a regression test with the exact adversarial shape CodeQL/the reviewer
cited (many tab pairs after the version digits), asserting sub-second parse.

Refs #1478

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

* test(ad-script): pin the annotation-line pattern's linear rejection directly

The entry-point adversarial case matched greedily even with the retired
regex (trim strips edge whitespace and per-line input carries no newline),
so it proved nothing about the pattern. The regression surface is the
pattern itself: an interior tab run with an x-newline tail fails the match,
which the retired form re-split quadratically (3.7s at 100k tabs) and the
\S anchor rejects in one attempt.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 20:21:38 +02:00
Michał Pierzchała 8a6ddbc11d fix(test): repair Android replay fixtures against live device reality (#1538)
* fix(test): repair Android replay fixtures against live device reality

Three Android fixture defects from the #1482/#1484 full-tier suite, none of
which ever executed in CI (both nightlies since failed on adb infra before
the suite ran). All three verified live on a fresh API 36 emulator with a
pixel_7-geometry AVD and a Release fixture APK:

- 01-navigation-scroll.ad clicked label=Catalog, but the expo-router
  NativeTabs cart badge leaks '0 new notifications' into the tab's content
  description even while hidden, and unselected native tabs expose no child
  text node - exact match can never hit. Target the composed label the
  device actually exposes (deterministic at fixture start: cart is 0 after
  --relaunch). The badge does not leak on iOS, so the iOS twin keeps
  label="Catalog".

- checkout-form-android.ad opened by iOS display name 'Agent Device
  Tester'; Android open resolves packages (the APK label is
  'Agentdevicelab'), so APP_NOT_INSTALLED was guaranteed. Use the package
  id, matching gesture-lab-android.ad.

- gesture-lab-android.ad aimed every gesture at y=700, above the gesture
  card (its targets span y754-1329 on pixel_7 geometry; the home screen
  gained content above the card since authoring). Re-aim pans inside the
  exact-two-pointer zone, flings on the image clear of that zone, and
  pinch/rotate/transform at the card center. Verified: full suite passes
  2/2 via the public test command (20 + 32 steps replayed).

Refs #1478

* docs(test): pin the Android gesture fixture's validated emulator geometry

The re-aimed coordinates are validated on CI's profile (pixel_7 1080x2400
@420); any booted emulator can receive them via test-app:replay:android, so
the fixture and README now say which geometry the numbers mean and what a
mismatch failure looks like. The checkout twin is selector-driven and
unconstrained.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 13:50:48 +02:00
Michał Pierzchała 67f3d09d95 refactor(daemon): session script publication behind one capability (#1478 P4a) (#1532)
* refactor(daemon): add the tagged script-publication aggregate

First step of P4a. Nine co-resident optional SessionState fields encode two
lifecycles plus a shared output target, with nothing in the shape saying the
lifecycles are disjoint — so readers re-derived that from field combinations
and writers had to remember which siblings to clear.

The aggregate makes both invariants structural: a session publishes nothing,
authors ordinarily, or is under repair; and force lives inside the target, so
retargeting replaces the authorization along with the path.

Three corrections after an adversarial review of the first draft:

- The target is a default|explicit union, not a mandatory path. A bare
  'open --save-script' arms with no path and lets the writer resolve a
  daemon-owned destination at write time, and force can be granted before any
  path exists. Eagerly materializing a default path would have silently changed
  retarget semantics, because today's check requires a previously persisted
  path — so 'open --save-script --force' then 'close --save-script=out.ad' is
  not currently a retarget and the grant survives. That behavior is preserved
  here and flagged in the docblock as a probable #1258 gap; tightening it is a
  product change and belongs in its own commit.

- The repair status relation is not linear. A failed commit followed by
  'replay --from' demotes complete back to armed, so demoteRepairToArmed exists
  and deliberately RETAINS the close receipt: the platform close already
  succeeded for that operation identity, and dropping it would re-dispatch a
  close on retry — which is also how a migrator ends up reaching for the
  caller-computed platformCloseSucceeded boolean the brief forbids.

- The receipt doc no longer claims it is set only at close-succeeded and later,
  since the demotion path makes {armed, receipt set} reachable.

Still to come in this PR: both projections, and the writer migration. Note the
brief's seven-file writer inventory omits session-open.ts, which holds the only
two writers of the authoring armed/aborted states.

Refs #1478

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

* refactor(daemon): migrate script publication onto the tagged aggregate (#1478 P4a)

The eight co-resident SessionState fields (scriptRecordingState, saveScriptPath,
saveScriptForce, saveScriptBoundary, saveScriptComplete, saveScriptCommitted,
repairPlatformCloseReceipt, repairSourcePath) are gone; SessionState.scriptPublication
holds the aggregate, and every writer migrated in this commit — no shadow state.

Two daemon-private projections own the writes, enforced by the R7 ownership gate:

- session-replay-transaction.ts (ReplaySessionTransaction): repair arm/demote/
  complete/abort, close receipts, and the uncommitted/boundary/sourcePath reads
  that idle-reap, tombstones, divergence-hold, and the recorder's exclusion key off.
- session-script-publication-capability.ts (SessionScriptPublication): authoring
  arm on open, the recorded --save-script flag ingress, active publication, the
  published transitions, and the effective per-target force decision (#1258).
  The writer keeps the commit transition so idempotence stays colocated with the
  atomic publish.

Failure/retry transitions pinned as the brief requires: platform-close failure
leaves state unchanged (no receipt, retry re-dispatches); publication failure
retains target+force+receipt (same-identity retry skips close dispatch); committed
and aborted are explicit terminal states that drop the receipt.

Design decisions resolved:

- Force retention across a default->explicit retarget is preserved as-is and
  still flagged in resolveScriptTarget's docblock as a probable #1258 gap;
  tightening it stays a separate product change.
- The never-armed 'close --save-script' whole-log publication folds into the
  authoring lifecycle (armed at the recorded close, published in the same
  request) instead of a fourth variant: every close path that reaches the write
  deletes the session, so the transient armed state cannot leak into
  'session save-script' eligibility, whose not-armed-before-this-journey
  rejection is untouched.

One real bug caught by the migrated tests and fixed in resolveScriptTarget: a
bare (pathless) re-arm collapsed an already-materialized explicit target back to
the daemon default, wiping the healed-sibling path on every per-step repair
re-arm and defeating the persisted-force preflight bypass. A bare re-arm now
keeps the previous target and only adds a live force grant.

R7 rows consolidated to one scriptPublication entry (three owners) and the
recordSession row narrowed; the R10 baseline drops to 22 writer-owned fields /
28 owner claims so the consolidation cannot regrow.

Gates: typecheck, lint, format, layering clean; 624 files / 5220 tests pass
(two known contention-flake timeouts reproduce only under full-suite load and
pass in isolation).

Refs #1478

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

* refactor(daemon): satisfy the Fallow gate by extracting decisions, not suppressing

- scriptPublicationTarget is module-private; both public target reads
  (scriptTargetPath/scriptTargetForce) go through it and nothing else did.
- validatePublicationEligibility splits into a pure ineligibility classifier
  and an error table, so the four rejections read as one decision each.
- prepareSaveScriptSession hands its two arm-time rejections (authoring
  re-arm, EEXIST preflight) to rejectSaveScriptArming and keeps only the
  demote-and-arm flow.
- The repair-record-exclusion provider scenario extracts its three phases
  (arm-and-hold, exclusion contrast, healed-script contract) into named
  helpers; the test body is the journey again.

Refs #1478

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 11:10:09 +02:00
Michał Pierzchała cbe1a57094 refactor(replay-test): extract packages/replay-test (#1478 P3b) (#1525)
* refactor(replay-test): source the manifest device vocabulary from the kernel

`session-test-types.ts` reached `ReplayScriptMetadata['platform']` and
`['target']` through `replay/script.ts` — the native `.ad` engine. A
format-neutral scheduler must not name an engine module, and P5 relocates that
engine into `packages/ad-replay` regardless, so the import had to go before the
scheduler can move.

Both members already resolve to neutral kernel types
(`Exclude<PlatformSelector, 'web'>` and `DeviceTarget` from
`@agent-device/kernel/device`), so this re-sources them directly and the
manifest shape is unchanged. Only the import direction differs.

First increment of P3b; the scheduler still has request-global, engine and
daemon imports to port before the physical move.

Refs #1478

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

* refactor(replay-test): inject the progress sink instead of reading a request global

The scheduler called `emitRequestProgress` in eight places, which reads a sink
out of a request-global `AsyncLocalStorage`. That is ambient authority a
format-neutral scheduler cannot hold once it lives in `packages/replay-test`,
and #1505 recorded it as a shrink-only R10 entry.

The host now injects the capability through the existing
`ReplayTestRuntimeDependencies` seam established in P3a, so no new seam is
invented. `session-replay.ts` supplies `emitProgress: emitRequestProgress`;
`src/request/progress.ts` keeps the sink and its AsyncLocalStorage binding for
every other caller.

The port is deliberately narrower than `RequestProgressSink`: it accepts only
`ReplayTestSuiteProgressEvent | ReplayTestProgressEvent`, so the scheduler is
not handed the ability to emit `CommandProgressEvent`.

Authority narrows again one hop down: `runReplayTestAttempt` spread the whole
dependency bag but uses three of its members and never publishes progress, so
it now takes `Pick<..., 'runReplay' | 'cleanupSession' | 'finalizeAttempt'>`.
That is why no runtime test fixture needed changing — the attempt runtime never
gained the capability in the first place.

Also drops the last two `replay/script.ts` type references from
`session-test-runtime.ts`, so the engine import is gone from that file too.

Reporter contract preserved: `session-test-reporter-values.test.ts` and
`session-test-reporter-values-maestro.test.ts` both pass unmodified (27 tests
green across the five scheduler suites). Typecheck clean.

Remaining scheduler boundary for P3b: `request/cancel.ts`, `replay/format.ts`,
`replay/script.ts` in discovery, `session-store.ts`, `daemon/types.ts`,
`replay-source-discovery.ts`, `core/dispatch*`, `utils/diagnostics.ts`.

Refs #1478

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

* refactor(replay-test): ask the host whether the suite is canceled

The scheduler called `isRequestCanceled(requestId)` in five places. That both
reaches a request-global registry and forces the scheduler to name a daemon
request id as the cancellation key — neither survives the move into
`packages/replay-test`.

The host now binds the predicate to its own request and passes
`isCanceled: () => boolean`. The scheduler asks a question it is entitled to
ask and learns nothing about how cancellation is tracked. `shouldStopReplayTestExecution`
takes the capability rather than a request id, so no scheduler function threads
a daemon identifier for this purpose any more.

`session-test-attempt.ts` and `session-test.ts` no longer import
`request/cancel.ts` at all. It remains in `session-test-runtime.ts`, which does
something different — `registerRequestAbort`, `markRequestCanceled` and the
parent-abort relay are cancellation *binding*, which the brief assigns to the
daemon adapter, so that split is its own step.

Behavior preserved: both pinned reporter characterizations pass unmodified,
32/33 across the five scheduler suites. The one failure is the pre-existing
P2/#1506 discovery-ordering regression, unrelated and untouched here.

Refs #1478

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

* refactor(replay-test): drop the dead request-tracking call from attempt ids

`buildReplayTestAttemptRequestId` wrapped its template in
`resolveRequestTrackingId`, pulling `request/cancel.ts` into the scheduler.

That wrapper substitutes a generated id only when its first argument is an
empty string. The template here always contains `:test:`, so it is never empty
and the wrapper always returned it unchanged — the call is unreachable in this
path. Probed all three shapes (explicit request id, suite-id fallback with a
shard, and degenerate empty inputs); every one returns the template verbatim.

Removing it takes `request/cancel.ts` out of discovery without altering a
single produced id. The scheduler mints attempt identity itself, which is what
the brief asks for.

Evidence the ids are byte-identical: the pinned reporter characterizations
assert exact session strings such as
`default:test:suite-reporter:1-02-retry:attempt-1` and pass unmodified —
30 tests green across the reporter, suite and discovery suites.

Refs #1478

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

* refactor(replay-test): move cancellation binding and diagnostics to the host

`session-test-runtime.ts` held the last two request-globals in the scheduler:
`request/cancel.ts` (registerRequestAbort, markRequestCanceled,
clearRequestCanceled, plus the parent-abort relay) and `utils/diagnostics.ts`.

These are different in kind from the earlier ports. The brief gives the daemon
adapter the job of mapping an attempt id to daemon request identifiers and
*binding cancellation*, while timeout policy stays scheduler-owned. So the
scheduler now receives a per-attempt capability with exactly two verbs —
`cancel()` on timeout and `release()` when the attempt settles — and every
registry interaction, including `relayReplayTestAbortFromParent`, moved to
`session-replay.ts` next to the rest of the adapter.

Diagnostics became a narrow publish capability for the same reason:
`emitDiagnostic` reads a request-global scope. The level vocabulary is spelled
out at the seam rather than imported, so nothing engine- or daemon-shaped
crosses it.

The runtime fixtures drive the real exported host binding rather than a stub.
They assert cancellation through `isRequestCanceled`, and a stubbed binding
would have kept those assertions passing while proving nothing.

24 tests green across the runtime, suite and both reporter characterizations,
which pass unmodified. Typecheck, lint and oxfmt clean.

Refs #1478

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

* refactor(replay-test): split discovery into host inspection and scheduler policy

discoverReplayTestEntries expanded paths, read every file, and called both
engines — readReplayScriptMetadata for .ad, inspectMaestroFlow for Maestro —
plus resolveReplayFormat to choose between them. Four imports a format-neutral
scheduler cannot hold.

Inspection is now the host's discoverSources capability. What stays in the
scheduler is the genuinely neutral half: which sources a --platform filter
runs, which it skips and with what message, and the empty-suite error.

The manifest carries exactly the four fields the scheduler consumes (platform,
target, retries, timeoutMs) plus the reporter's title, per the brief's
instruction not to add more without a demonstrated call site.

The platform tag is what removes the last format leak. The filter used to ask
resolveReplayFormat(...) === 'maestro' to decide whether a missing platform was
disqualifying. It now reads a tag: caller-bound means the invocation supplies
the platform, unspecified means the source declared none. Maestro is what
caller-bound looks like from the scheduler's side, and the format cannot be
recovered from it.

Discovery tests drive the real inspection capability, writing actual .ad and
Maestro sources — a stubbed host half would have kept them green while proving
nothing about the composition they exist to pin.

35 tests green across discovery, suite, runtime and both reporter
characterizations, which pass unmodified. The Maestro one is the direct check
that titles still flow, since they now arrive via the manifest.

Refs #1478

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

* refactor(replay-test): build attempt ids from named segments; trim comments

Review feedback on the attempt-id builder: the comment explained a deletion
that git already records, and it sat above an opaque template literal.

The id is now a segment list joined on ':', so its shape is readable without
prose. Output is byte-identical — the reporter characterizations assert exact
session and attempt strings and pass unmodified.

Applied the same standard to four other docblocks in this PR that narrated
what the code used to do rather than what it does. The durable 'why' stays:
which side of the seam owns what, and why the vocabulary is neutral. The
migration history goes, since git carries it and these docblocks will outlive
the migration.

Refs #1478

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

* refactor(replay-test): move shard device binding to the host

buildReplayTestShardPlan called listDeviceInventory to discover what to shard
across, and buildReplayTestShardFlags constructed daemon CommandFlags for the
nested request. Inventory enumeration, allowlists, simulator set paths,
explicit --device selectors and the too-few-devices error are host concerns;
what is scheduler-owned is deciding how many shards exist and which entries
each one runs.

The scheduler now receives resolved shard targets through a capability. The
target is neutral: id and name for session labels and progress metadata, plus
platform and target, which are already kernel vocabulary. DeviceInfo no longer
crosses into scheduling.

One behavior note: an explicit --device selector could in principle name a web
target, which is not a shardable device. That is now rejected with INVALID_ARGS
rather than widening the neutral platform vocabulary to carry something the
scheduler can never run. Implicit selection already filtered to mobile.

919 of 920 handler tests pass. The one failure, session-test-runner.test.ts
'binds each replay script to its declared platform metadata', fails identically
on clean origin/main in this container and is unrelated: directory discovery
walks with opendirSync/readSync and directory results are deduped but not
sorted, while glob results are sorted, so suite order is filesystem-dependent.

Refs #1478

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

* refactor(replay-test): extract packages/replay-test behind a façade

Completes the P3b extraction. The scheduler, attempt runtime, discovery
policy, sharding distribution, artifacts and neutral types now live in
packages/replay-test/src/internal/, with one package-root export.

The façade takes a neutral ReplayTestSuiteRequest and returns a tagged
ReplayTestSuiteOutcome. DaemonRequest, DaemonResponse and CommandFlags no
longer reach the scheduler; the adapter translates flags and meta in, and the
outcome back to a daemon response. Eight flags were read by the scheduler and
each became a field it owns.

Host work moved to daemon adapters: source inspection (both engines and format
routing), shard device binding and shard-flag parsing, and artifacts-dir home
expansion, which is why the package can resolve paths without SessionStore.

The one remaining shared concern was the timing trace: the host writes video
lifecycle events into the same trace the scheduler owns. Rather than export a
writer from the façade, each attempt hands the host an appendTimingEvent
closure, so the trace format stays private and the authority is scoped to that
attempt.

Tests mirror the topology. Discovery tests split along the seam they now
cross: ordering, traversal and routing are pinned host-side against real files,
filtering policy is pinned in the package against fake sources. The runtime
tests assert the scheduler's cancellation obligation (cancel once on timeout,
always release) against a recording binding, and a new daemon test pins the
adapter's half — registry entries, the parent-abort relay, and detach on
release — so that coverage moved rather than disappeared.

R10 retargeted to packages/replay-test/src/ and the zone ranked alongside
maestro. R11 confirms zero root-src imports from the package.

914 of 915 handler and package tests pass. The one failure,
session-test-runner 'binds each replay script to its declared platform
metadata', fails identically on clean main here: directory discovery walks with
opendirSync and dedupes without sorting, while globs sort, so suite order is
filesystem-dependent.

Refs #1478

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

* refactor(daemon): simplify replay-test request translation

Fallow flagged toReplayTestSuiteRequest at 14 cyclomatic in 18 lines. The
branches were self-inflicted: every req.flags?.x is one, and each optional
field was written as a conditional spread to avoid setting an undefined key.

exactOptionalPropertyTypes is not enabled, so assigning undefined to an
optional field is equivalent and the spreads bought nothing. Destructuring
flags once and extracting two flag readers removes most of the rest.

One correctness note on the simplification itself: the first version used
`artifactsDir && expandHome(...)`, which returns '' for an empty-string flag
where the previous code called expandHome(''). Replaced with an explicit
undefined check so the empty-string path is unchanged.

Refs #1478

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

* test(live): share the replay test-suite harness across iOS and Android

Both live journeys invoked the public test command and then re-derived the
same value-contract assertions by hand — suite totals, per-script status,
replay counts, non-empty JUnit. Those are claims about the published suite
result and are identical on every platform, and they had already drifted: iOS
iterated with readReplayCommands inline, Android cast data.tests at the call
site.

The shared helper owns exactly that boundary. It takes the caller's runStep
rather than binding a context type, so it is not a platform-configured runner
and cannot template a platform's journey.

Everything a platform genuinely differs on stays with the caller: which
scripts run, the retry policy (iOS 2, Android none — itself a claim worth
keeping), which commands each script exercises, and the behavioral evidence.
Both callers keep every verify* call they had.

67 lines removed, 15 added.

Residual risk: this container has no iOS or Android devices, so the live suites
could not be executed here. Typecheck and lint pass; the harness needs a run on
real targets before the claim that behavior is unchanged is evidence rather
than inference.

Refs #1478

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

* test: pin directory enumeration in the platform-binding suite test

The test wrote two scripts into a temp directory and assumed discovery would
return them in creation order. Directory expansion deliberately preserves
filesystem order to match Maestro — only glob expansion sorts, and 'preserves
Maestro directory filesystem order' pins that with a mocked opendirSync. So the
ordering contract is correct; this test's assumption about enumeration was not.

It passes on CI, where small directories usually enumerate in creation order,
and fails on filesystems that do not — identically on clean main, where the
platform-to-script binding appears reversed.

Pinning enumeration the way the discovery tests already do keeps the subject
intact (each script binds to ITS declared platform, and session numbering
follows discovery order) without depending on the host filesystem. The fs
import became a default import because vi.spyOn cannot redefine an ESM
namespace export.

915 of 915 handler and package tests now pass here.

Refs #1478

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

* test: scope the enumeration spy and restore it in a finally

The spy I added restored only on the happy path and asserted on its argument
inside the mock implementation. Either would misfire for anything else sharing
the worker: an assertion thrown from inside fs, or a leaked global opendirSync,
surfaces as a worker crash with no failed test rather than a readable failure.

It now delegates to the real implementation for any directory but this suite's
own, and restores in a finally.

Refs #1478

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

* fix(replay-test): put package tests where they are actually run

Review found the moved package tests were neither executed nor typechecked.
They sat under packages/replay-test/test/, but vitest's unit-core lane includes
packages/*/src/**/*.test.ts, and neither the root nor the package tsconfig
covers a top-level test directory. A plain unit-core run discovered zero files
under the package.

That is why they looked green: my earlier runs passed those paths explicitly on
the command line, which masked that the default run skipped them. The count is
the proof — 550 files/4741 tests before, 553/4753 now, and the delta is exactly
the three files and twelve tests that were being skipped.

The runtime test also imported runReplayTestAttempt from the package specifier,
which the facade does not export. It would have failed the moment it was
discovered. It now imports internally, like the rest of the internal tests.

Also removed replayTestAttemptFailure from the facade: zero consumers outside
the package, so exporting it widened the boundary for nothing. P3 asks for a
one-function facade.

553 test files and 4753 tests pass; lint and the layering guard are clean.

Refs #1478

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

* style: format the facade after removing the unused export

A scripted edit removed the export line but left a stray blank line; oxfmt was
not re-run on that file afterward, so Lint & Format caught what pnpm lint
alone does not.

Refs #1478

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

* fix(replay-test): typecheck the package and fix a type-only import

Review found the moved package tests were transpiled by vitest but never
typechecked: the root typecheck script builds six packages via tsc -b and
packages/replay-test was not among them, so its tsconfig was never used.

That hid a real TS2459. session-test-runtime.test.ts imported
ReplayTestAttemptOutcome from ../session-test-runtime.ts, which imports that
type but does not re-export it. It now imports from ../session-test-types.ts,
where the type is defined.

Adding the package to the tsc -b list closes the gap. Verified empirically
rather than assumed: planting a string-to-number error in a package test makes
typecheck fail, and removing it makes it pass. This is the second finding of
the same shape on this PR — first the tests were not discovered by vitest, now
they were not covered by typecheck — so the gate was confirmed to reach the
files rather than trusted to.

12 package tests pass, lint, format and the layering guard are clean, and
typecheck is clean with the package included.

Refs #1478

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 20:16:18 +02:00
Michał Pierzchała e669676bc8 test: fix Android observability scenario to Android contracts (#1531)
The full:observability-artifacts scenario (#1482/#1484) had never executed
end-to-end: both nightly Android Full Emulator Suite runs since merge died
on adb infra before the suite ran, and a live run fails deterministically
at its first perf assertion. Fixing that revealed four more latent
failures, each written against iOS or remote-daemon behavior the Android
live run does not have. Validated with two consecutive green full-tier
runs on a dedicated Pixel 9 Pro XL emulator.

- perf metrics: assert totalPssKb (Android's required meminfo field)
  instead of the Apple-only residentMemoryKb.
- presses: reveal the Quick-actions card with scroll steps before
  pressing home-open-catalog/home-open-settings — Android snapshots only
  contain on-screen nodes — and restore scroll top before waiting on the
  home title, since scroll position persists across tab switches.
- batch get: target id="dismiss-notice" (a node that owns its text);
  Android resolves the home-title container to a child's text (the
  subtitle), unlike iOS's container label.
- events: run an explicit snapshot so the timeline assertion holds when
  the scenario runs standalone under AGENT_DEVICE_ANDROID_E2E_SCENARIOS.
- artifacts: assert the local-client contract — trace-log tracked,
  downloadable, consumed; screen-recording inventory entries only exist
  for remote clients (artifacts without a client localPath are never
  tracked). Also stop consuming the download response body in the assert
  message before arrayBuffer() reads it.
2026-07-31 19:48:45 +02:00
Michał Pierzchała 14b71c96f1 test: narrow Limrun public type contract (#1528) 2026-07-31 18:02:34 +02:00
Michał Pierzchała 2e0ed0a41d fix: normalize physical iOS landscape taps (#1520)
* fix: normalize physical iOS landscape taps

* fix: preserve iOS selector tap fallback

* fix: normalize stale iOS screenshot dimensions
2026-07-31 15:36:21 +02:00
Michał Pierzchała da93191201 refactor: move Limrun provider behind package facade (#1518)
* refactor: move Limrun provider behind package facade

* fix: preserve Limrun public provider types

* fix: tighten Limrun provider facade boundaries

* test: harden Limrun compatibility coverage

* fix: narrow Limrun public type exports

* fix: narrow Limrun provider exports
2026-07-31 15:35:49 +02:00
Michał Pierzchała e3cdbcdb37 fix(test): give the iOS scroll search a capture-sized wait budget (#1514)
`assertElementTextAfterScrolling` waited 1000ms per attempt. The wait's whole
budget goes to its first capture, so that budget has to cover one snapshot of
the current surface; on a loaded simulator it does not, and every attempt fails
with `wait_capture_stalled` instead of reporting the element as off-screen.

This broke the iOS Smoke Tests lane on main. #1484 moved the smoke automation
scenario onto this helper for `automation-press` and `automation-longpress`;
before that it was only reached from the full lane, so the tight budget went
unnoticed. Every sibling wait in the same scenario already budgets 2500ms or
more (`label="Settings"` 10000, `alert wait` 5000, `wait text` 2500).

Two changes:

- Raise the per-attempt budget to 2500ms, matching the nearest sibling.
- Stop spending a scroll attempt on a stalled capture. A stall means the
  snapshot never came back, so the surface was never read — it is not evidence
  the element is off-screen, and scrolling on it moves the surface for an
  unrelated reason. Two stall retries absorb a slow runner; a genuine absence
  still consumes attempts and still fails.

The final assertion now carries the last wait's JSON, so a future failure says
whether it stalled or genuinely never found the element.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 09:10:44 +02:00
Michał Pierzchała b125435989 refactor: extract WebDriver provider package (#1504)
* refactor: extract webdriver provider package

* refactor: consolidate shared XML codec
2026-07-31 09:10:04 +02:00
Michał Pierzchała 32b9db2d7a test: add Android full emulator coverage (#1484)
* test: add Android full emulator coverage

* ci: package Android helpers before nightly coverage

* fix: harden Android nightly runtime evidence

* fix: expose trace artifacts in MCP schema

* style: format Android coverage manifest

* fix: address Android coverage review findings

* refactor: share live device coverage helpers

* fix: restore fixture landmarks in device smokes

* refactor: centralize live artifact assertions

* fix: normalize fixture canary visibility
2026-07-30 21:07:06 +02:00
Michał Pierzchała 0e51007b04 refactor: isolate maestro engine package (#1506)
* refactor: isolate maestro engine package

* perf: deepen maestro facade boundaries
2026-07-30 20:58:20 +02:00
Michał Pierzchała e70fac7b44 fix(ios): deny viewport during capability admission on Apple targets (#1507)
The capability matrix admitted `viewport` on every Apple simulator and
device while no Apple interactor implements `setViewport`, so the command
was routed to the device and only rejected inside dispatch with the
backend-shaped "viewport is not supported by this backend". Nothing can
serve it there: Apple screen geometry is fixed by the selected device type
and neither simctl nor XCTest exposes a resize primitive, so viewport is a
web-surface contract (its CLI summary and docs already say so).

Deny it in the descriptor's capability facet, matching the adjacent Android
denial, and give the Apple family an unsupported hint that names the surface
that does support it. Admission now fails before the request reaches the
device: "viewport is not supported on this device" with supportedOn: web.

The iOS simulator coverage manifest's known-gap row becomes a
capability-denial row owned by the static coverage test, which retires the
whole known-gap level and the `full:known-gaps` live scenario that existed
only to pin this failure.

Closes #1407
2026-07-30 20:22:17 +02:00
Michał Pierzchała 0ee2a86129 refactor: extract contracts workspace package (#1499)
* refactor: extract contracts workspace package

* fix: preserve screenshot diff result contract

* test: stabilize Android keyboard smoke
2026-07-30 17:07:46 +02:00
Michał Pierzchała cd9a7ce41b test(android): add comprehensive emulator E2E coverage (#1482)
* test(android): add catalog emulator smoke coverage

* test(android): use stable snapshot diff mutation

* test(android): assert actual back destination

* test(android): separate keyboard and fill IMEs

* fix(ci): keep Android timing report in one shell

* refactor(test): simplify simulator e2e coverage

* test(android): assert stable diff landmarks

* fix(android): release snapshot helper gracefully

* fix(android): fully release snapshot helper runtime

* test(android): report coverage classifications

* fix(android): stabilize accessibility root capture

* fix(android): bound UiAutomation connection

* ci: upload worktree daemon diagnostics

* fix(android): cancel stalled wait captures

* fix(android): bound helper fallback lifecycle

* fix(android): harden emulator e2e lifecycle

* fix: align e2e changes with kernel package

* test(android): prove alert helper reuse directly

* fix(android): cancel stalled settle captures

* fix(android): separate helper retirement budgets
2026-07-30 15:10:21 +02:00
Michał Pierzchała 2e6849ddb6 test: stabilize parallel provider scenarios (#1497) 2026-07-30 14:41:37 +02:00
Michał Pierzchała 117cf4ff68 test(ios): fix nightly fixture selector drift (#1492)
* test(ios): fix nightly fixture selector drift

* test(ios): harden fixture selector coverage
2026-07-30 12:14:57 +02:00
Michał Pierzchała 76453add71 refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0) (#1494)
* refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0)

Extend the workspace with packages/* and move the kernel behind an
enforced public API: packages/kernel with nine consumer-earned subpath
exports (errors, device, snapshot, contracts, collections, rect,
redaction, daemon-error, bounds — the last absorbed from utils as Rect
vocabulary). Every kernel import repo-wide becomes the
@agent-device/kernel/<sub> specifier; kernel tests move to
src/__tests__/kernel/ and exercise the package surface. The root
declares the package in devDependencies (workspace:*), tsdown bundles
it (noExternal) so the published artifact and its runtime dependency
manifest are unchanged.

Gate rewiring in the same change, per the W0 brief:
- R1 kernel-sink retires (physically subsumed); new R11
  package-boundaries guards no-root-back-imports, relative tunnelling
  past exports maps, undeclared workspace deps, and non-exported
  subpaths, with runtime resolution pins via import.meta.resolve.
- resolveImportEdges and mutation ownership follow workspace
  specifiers through exports maps, keeping R4 cycle checks, depgraph,
  and derived test ownership connected across the seam (kernel-errors
  still owns 495 tests). listSourceFiles includes packages/*/src.
- kernel becomes an unranked zone; mutation registry, stryker mutate
  globs, and the mutation-affected workflow path filter move to
  packages/kernel/src/errors.ts.
- check:affected gains packages/ ownership (manifests fail open);
  vitest and coverage include packages/*/src; fallow ignores
  packages/** (its resolver cannot follow workspace specifiers).
- The affected-selector CI job installs dependencies: its closure now
  crosses workspace specifiers, and the R8 relative exception is
  unsafe for production src files (Node ESM does not realpath, so dual
  specifier/relative loads would instantiate modules twice). The R8
  zero-dep set is pinned empty with that rationale.

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

* fix: address W0 review — mutation sandbox, exports-map resolution, tsc -b

Review findings on #1494, all five:

1. contracts-schema-public.test.ts reads the kernel source at its
   packages/ path (fs access invisible to the codemod and typecheck).
2. Mutation lane: Stryker sandboxes the tree but pnpm's node_modules
   symlink resolves @agent-device/* back to the real repo, so mutants
   in the sandbox never load and vitest.related finds no tests.
   vitest.mutation.config.ts now aliases each EXPORTED specifier to
   its source (derived from exports maps, never a wildcard), keeping
   resolution inside the mutated tree. Validated: kernel-errors module
   runs end to end (dry run 3,984 tests, mutants killed, exit 0).
3. Layering/depgraph resolve workspace specifiers through the
   exports-derived map (workspaceSpecifierTargets) instead of
   reconstructing paths, so '.'-facade packages resolve; the
   positional fallback remains only for map-less fixtures (P0 pin).
4. Per-package project references implemented: packages/kernel is
   composite (emitDeclarationOnly -> dist-types, gitignored), the root
   references it, and typecheck becomes tsc -b — probed to catch type
   errors on both sides under TypeScript 7 native.
5. R11's relative-route exception now requires membership in an actual
   R8 zero-dep job closure (zeroDepClosureFiles walks entries), not
   mere scripts/ placement — closing the dual-instantiation bypass.

Also from review discussion: daemon-error moves out of the kernel
package to src/client/ — its consumers (cli, client facade) rehydrate
wire DaemonErrors client-side; the daemon only produces them. Kernel
drops to 8 exported subpaths before any of them ship.

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

* refactor: one exports-map reader for mutation alias and ownership

Fallow flagged workspaceExportAliases (cognitive 15, CRAP 90). The
manifest-reading logic already exists as workspaceSpecifierTargets in
scripts/layering/package-boundaries.ts, so both the Stryker sandbox
alias table and the mutation ownership walker now consume it instead
of carrying near-clones. Behavior unchanged; mutation suite 45/45 and
changed-code fallow green.

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

* fix: composite kernel without a root references edge

FreeRange runs plain `tsc -p tsconfig.json`, and a root `references`
entry makes non-build-mode TypeScript demand the referenced project's
built declarations (TS6305) — a standing "build first" tax on every
plain -p consumer (fr, editors). Keep the per-package composite
project and build it in typecheck (`tsc -b packages/kernel` before the
root and examples/sdk passes), but drop the root references edge: root
consumption resolves through exports to source, identical to runtime
and to the bundler. Probed: plain -p green with no prebuilt output;
kernel-side type errors still caught by its own build.

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

* fix: R11 uses the layering parser; mutation config is a fallow entry

Review blockers on #1494:

- R11's private single-quote regex could miss a double-quoted or
  re-export route into packages/*/src. specifierSites now delegates to
  the layering model's parseImports (both quote styles, side-effect
  imports, re-exports, dynamic imports), with direct regressions for
  each formerly-invisible form.
- vitest.mutation.config.ts becomes a declared fallow entry instead of
  a tolerated unused-file finding: the full-repo audit now reports it
  reachable (unused files 2 -> 1; the remainder predates this PR).

FreeRange clean-checkout evidence: with packages/kernel/dist-types and
every *.tsbuildinfo deleted, `pnpm check:freerange` reports 0 findings
on this head — the TS6305 topology died with the root references edge
in the previous commit; check:freerange has no build precondition.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 12:12:46 +02:00
Michał Pierzchała ee12f4d5bc fix: publish replay refs after response projection (#1489)
* fix: publish replay refs after response projection

* fix: retain replay device lock through finalization

* fix: verify Apple text wait misses with snapshot

* fix: lock replay device before open effects

* fix: lock replay device before open preparation
2026-07-30 10:03:19 +02:00
Michał Pierzchała 9e75eaba3e fix: preserve clear state before iOS launch URLs (#1486)
* fix: preserve clear state before iOS launch URLs

* test: prove iOS clear-state launch URL flow

* fix: await iOS keyboard dismissal
2026-07-29 18:20:14 +02:00
devin-ai-integration[bot] 885c1486bb test(ci): single-retry policy for enumerated contention-flaky files (timeouts only) (#1448)
* test(ci): single-retry policy for enumerated contention-flaky files

* fix: satisfy fallow

* test(ci): read failures through a lane reporter so timeouts stay distinguishable

* test(ci): cover the lane reporter and drop its duplicated boilerplate

* chore(fallow): own the retry lane's tool-loaded export seams

* test(ci): block retries on non-test failures and classify timeouts structurally

* test(ci): decide retry eligibility from runner metadata and route gate verdicts through blockers

* refactor(ci): name the retry policy's rules in code instead of comments

* test(ci): mark runner-aborted timeouts inside the runner instead of inferring them

* test(ci): make timeout provenance a per-run secret, not a writable flag

Cover direct task.meta mutation in the real child-Vitest fixture gate.

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

* test(ci): retry the failed files in the first run's project and coverage modes

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

* fix: drop deleted repo-health file from the retry list after #1480

Rebase onto main post-#1480: the SkillGym/repo-health descope deleted
scripts/repo-health/run.test.ts, whose CONTENTION_RETRY_FILES entry
would now fail this PR's own missing-file check, and inlined the
slow-test budgets into the reporter, resolving the budgets-module
import. Envelope comments now point at scripts/lib/lane-envelope.ts
instead of the closed #1430.

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

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-29 12:53:33 +02:00
Michał Pierzchała 53e4be5f86 Remove SkillGym suite and repo-health snapshot infrastructure (#1480)
* chore: drop SkillGym and the repo-health aggregator (#1412 descope)

Remove the SkillGym harness (test/skillgym/), its check-affected lane,
package scripts, and devDependency — the help-conformance bench is now
the single non-gating small-model oracle. skills/ markdown classifies
as docs in the affected-check selector instead of failing open.

Remove scripts/repo-health: its only gating assertion duplicated the
Layering Guard job, its case-count metric imported the deleted SkillGym
suite, and its sole planned consumer (#1424 / PR #1477) was closed with
the Track C descope on #1412.

Verified: check-affected node --test suites, oxfmt, oxlint, tsc,
check:layering, fallow audit vs origin/main, and the full unit suite
(unit-core + subprocess-stub) all pass.

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

* fix(scripts): fold slow-test budgets into the reporter for production-exports

The Fallow production-exports gate flagged all three budget exports:
their in-file consumer (SLOW_TEST_RATCHET) and the repo-health entry
point that kept the module reachable were both removed in the descope,
leaving the config-loaded reporter as the only consumer — invisible to
--production analysis. The data-only module's second consumer is gone,
so per the boundaries-are-earned norm the constants move into the
reporter instead of gaining a suppression.

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

* docs: align skills/ format policy and purge last SkillGym mention

Address both P2 review findings on #1480: the testing-matrix row and
the selector's formatGate both still claimed oxfmt covers skills/,
while selectChecks classifies skills/*.md docs-only (oxfmt ignores
**/*.md, so the claim was a no-op even before). The matrix now states
the docs-only policy and formatGate drops the dead underSkills fact.
The merged examples/README.md index (from #1469) loses its skillgym
mention.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-29 11:45:22 +02:00
Michał Pierzchała 6ab59630e4 ci(android): trust cached fixture APKs (#1479)
* ci(android): trust cached fixture APKs

* fix(ci): keep Android emulator script lines self-contained

* fix(ci): assert Android fixture smoke evidence

* fix(ci): harden Android fixture smoke cache path

* test(ci): verify Android fixture foreground app

* fix(ci): read Android snapshot response envelope
2026-07-28 22:51:01 +02:00
Michał Pierzchała 630dc7c99b feat(examples): add runnable Node.js SDK examples under examples/sdk/ (#1469)
* feat(examples): add runnable Node.js SDK examples under examples/sdk/

examples/test-app is a fixture and the repo's only prior examples/
content; the real SDK usage patterns lived only in
website/docs/docs/client-api.md with no runnable script anywhere.

Adds four standalone, typechecked examples covering the minimum surface
from #1463: root client session (create -> open -> snapshot/tap ->
close with typed error handling), agent-device/metro
(normalizeBaseUrl/resolveRuntimeTransport), agent-device/contracts
(centerOfRect on a snapshot node), and agent-device/batch (runBatch for
a custom transport). Each imports the published `agent-device/...`
subpaths rather than relative src/ paths.

examples/sdk/tsconfig.json path-maps those subpaths to src/sdk/ so
`pnpm typecheck` (now also run against this tsconfig) checks the
examples in CI without a prior build, workspace link, or publish step.
Running an example for real still resolves `agent-device` as a
self-referencing package after `pnpm build`.

src/__tests__/client-api-examples-drift.test.ts guards the examples
against drifting from client-api.md's subpath API manifest in both
directions, picked up automatically by the existing unit-core vitest
project (no new script or workflow needed).

examples/README.md indexes the new examples and notes that test-app/
remains a fixture, not an example; it is not renamed or moved.

Refs #1463

* fix: address Fallow findings on the new SDK examples

Fallow flagged the four examples/sdk/*.ts files as unused files (not
reachable from any entry point) and three functions as high complexity.

- Register the examples as manual entry points in .fallowrc.json,
  matching how other standalone scripts (scripts/patch-xcuitest-runner-icon.ts,
  scripts/runner-request-count/run.ts) are already declared.
- Reduce complexity in client-session.ts and contracts-result.ts by
  extracting device-resolution/error-reporting and rect-assertion
  helpers out of main().
- Reduce complexity in the drift guard's parseSubpathManifest by
  splitting bullet-matching and backtick-name extraction into their
  own functions.

Verified: pnpm check:fallow --base <PR base sha> now reports no issues,
and pnpm check:tooling / pnpm test:unit stay green.

Refs #1463

* fix: compile client-api.md's actual code snippets, not just its symbol manifest

Addresses review feedback on #1463's drift guard: the existing guard only
parsed the doc's "Public subpath API" bullet manifest and compared imported
symbol names, so a fenced ```ts snippet could drift or stop compiling
without the guard noticing.

Added test/integration/client-api-doc-snippets.test.ts, which extracts every
fenced ```ts block from client-api.md and typechecks it against the real
agent-device/* sources (reusing examples/sdk/tsconfig.json's existing paths
mapping, read via `tsc --showConfig` so there's one source of truth). Free
identifiers that continue a `client`/`snapshot` from an earlier snippet are
stubbed — typed against the real SDK return type, not `any`, so continuation
snippets still get real checking. Lives in the Node integration lane
(test/integration/*.test.ts), not vitest's unit-core: it spawns a real tsc
Program, well past the unit suite's 2.5s budget.

Running this check against the existing doc surfaced real, pre-existing
snippet bugs (unrelated to the new examples), fixed here:
- "sessions.artifacts": `result.cloudArtifacts` accessed without narrowing
  the `CloudArtifactsResult | DaemonArtifactsResult` union first.
- "Device cloud sessions": `platform`/`device` were passed into the client
  constructor config, which doesn't accept them; moved to the `apps.open()`
  call where those fields actually belong.
- "Android ADB providers": the inline `exec` handler had no parameter types,
  so it failed under strict/noImplicitAny; annotated with the real
  `AndroidAdbExecutorOptions` type.

Two further gaps the check surfaced are pre-existing product/API-surface
questions out of scope for this PR (not the new examples), so they're
allowlisted in KNOWN_DOC_GAPS with comments rather than silently patched:
- "Remote Metro helpers" documents prepareRemoteMetro/reloadRemoteMetro/
  stopMetroTunnel/resolveRemoteConfigProfile as public, but none of them are
  exported from agent-device/metro or agent-device/remote-config today.
- "Web sessions"/audio probe pass `platform` to `observability.network()`/
  `.audio()`, but NetworkOptions/AudioOptions have no `platform` field even
  though the CLI's network/audio commands accept `--platform`.

Refs #1463

* fix: close the doc-snippet compiler's stubbing hole and the two suppressed gaps

Addresses the second round of review feedback on #1463's drift guard:

1. stubFreeNamesAndRecompile auto-stubbed every "Cannot find name" as `any`,
   so a typo like `cliet.apps.open()` would silently pass on the second
   compile. It now only stubs identifiers in an explicit allowlist
   (KNOWN_FREE_NAME_STUB_TYPES) — the real SDK-derived continuations
   (`client`, `androidClient`, `snapshot`) plus the doc's own invented
   host-glue names, each typed precisely rather than loosely. Anything else
   is left as a real compile failure. Added a regression test that feeds a
   `cliet` typo through the guard and asserts it fails.

2. KNOWN_DOC_GAPS filtered six real compiler errors out of the final
   assertion while the test claimed every snippet compiles. Investigated
   both and fixed the actual contracts instead of suppressing them:
   - `prepareMetroRuntime`/`reloadMetro` (src/metro/client-metro.ts) and
     `stopMetroTunnel` (src/metro/metro.ts) already existed and matched the
     doc's described workflow almost exactly (same result shape) but were
     never re-exported from `agent-device/metro`; same for
     `resolveRemoteConfigProfile` and `agent-device/remote-config`. Added
     the four exports and fixed the doc's stale function names
     (`prepareRemoteMetro`/`reloadRemoteMetro`) and one stale field name
     (`profileKey` -> `companionProfileKey` on the prepare call) to match.
   - `NetworkOptions`/`AudioOptions` (src/contracts/client-observability.ts)
     had no `platform` field even though the CLI's `network`/`audio`
     commands accept `--platform` for the same use case, and the client
     methods already forward the options object to the daemon generically
     (`executeCommand('network'|'audio', options)`) — so this was a type
     gap, not a runtime one. Switched both from AgentDeviceRequestOverrides
     to DeviceCommandBaseOptions (matching PerfOptions' existing pattern),
     closing the gap for real instead of stripping `platform` from the doc.
   - The "Android installFromSource()" snippet was missing its
     `createAgentDeviceClient` import outright; added it.
   KNOWN_DOC_GAPS is gone — every fenced snippet now compiles for real, and
   the test's assertion matches what it claims.

3. Switched the raw `execFileSync` calls to `runCmdSync` from
   src/utils/exec.ts, per AGENTS.md's process-execution invariant (this is
   a .ts integration test, not a packaging fixture that needs to stay
   dependency-free).

Refs #1463

* docs: fix stale reloadRemoteMetro() prose reference to reloadMetro()

The prose right after the Remote Metro helpers snippet still named the old
function; the compile guard only checks the fenced snippet, not surrounding
prose, so it didn't catch this leftover from the prior rename.

Refs #1463

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-28 21:36:07 +02:00
Michał Pierzchała 4e4ecdea0d test(ios): expand simulator e2e coverage (#1408)
* test(ios): expand simulator e2e coverage

* test(ios): make coverage checks host portable

* test(ios): handle deep link confirmation

* test(ios): fix deep link prompt selector

* ci: stabilize full simulator nightly

* test(ios): stabilize permission prompt lifecycle

* test(e2e): wait for route-specific landmarks

* test(e2e): reset permissions from inactive app

* test(e2e): redeliver trusted cold deep links

* test(ios): verify orientation native readback

* test(ios): stabilize simulator permission coverage

* test(ios): wait for tab target after deep link

* test(ios): paginate full event timeline

* test(ios): simplify event pagination coverage

* test(ios): stabilize simulator e2e coverage

* test(ios): model simulator recorder lifetime

* ci(test-app): cache fixture dependencies

* fix(ci): isolate test app cache by node

* fix(ios): settle fixture route navigation

* test(ci): waive unbenchmarked ios system UI help

* fix(ios): tolerate delayed simulator scale lookup

* 0.20.1

* test(ci): remove superseded system UI waiver

* fix(ios): harden simulator e2e reliability

* chore: clarify Apple runner CI steps

* fix(ios): wait before fixture home snapshot

* fix(ios): require exact catalog navigation

* chore(ci): format rebased workflows

* fix(ios): retry unobserved fixture navigation

* refactor(test): remove iOS e2e workarounds

* fix(ci): verify fixture artifact provenance

* fix(ci): align fixture artifact fingerprints

* fix(ci): use unified Android helper packager

* perf(ci): scope fixture build concurrency

* chore: format fixture artifact tests

* test(ios): update split Apple coverage owner

* fix(ios): accept deep-link confirmation alerts
2026-07-28 20:08:45 +02:00
Michał Pierzchała ce7b7a93d9 feat: expose Limrun runtime (#1476)
* feat: expose Limrun runtime

* fix: address Limrun export feedback
2026-07-28 20:05:33 +02:00
Michał Pierzchała 0557fee626 test: smoke every package.json export subpath from the packed package (#1452)
The installed-package test only exercised 3 of 11 export subpaths (io,
metro, and contracts as a bare side-effect import), so a broken subpath
export shipped silently despite external consumers importing all 11.

The consumer script now derives the subpath list from the installed
package.json exports map and runs a named-export smoke invocation per
subpath; a subpath added or removed without updating the smoke table
fails the deepEqual gate. remote-config is type-only at runtime, so
module resolution is its entire check.
2026-07-28 16:42:48 +02:00
devin-ai-integration[bot] 7402a40bac test: enumerate error-code recovery quizzes in a unit-lane gate (#1445)
* test: enumerate error-code recovery quizzes in a unit-lane gate

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

* test: mark recovery quizzes structurally and derive retriability from the enumeration

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-28 15:46:27 +02:00
Michał Pierzchała c984bd5902 fix(ci): upload hidden diagnostics (#1443)
* fix(ci): upload hidden diagnostics

* fix(ci): capture all hidden diagnostics
2026-07-28 12:30:42 +02:00
devin-ai-integration[bot] e545544dfa test(daemon): seeded concurrency torture lane for session/lease/lock invariants (#1439)
* test(daemon): seeded concurrency torture lane for session/lease/lock invariants

Refs #1416

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

* test(daemon): drive torture lane through real lock plan + review fixes

- derive each op's lock plan from production resolveRequestExecutionLockKeys
  via a fake device-inventory provider, so reverting the router's same-device
  serialization trips the overlap invariant (verified)
- assert exact replay: full scheduler trace, terminal outcome, contention
- assert real same-device lock contention in the sweep + a forced 2-client case
- split harness into bindings/invariants/envelope modules (all <500 LOC)
- emit #1430 scheduled-lane envelope (schema/SHA/hash/seed range/duration/result)
  and upload it from the nightly workflow

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

* test(daemon): pass claim data as plain view accessors (fallow)

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

* test(daemon): gate lock plan on shouldLockSessionExecution; sweep replay + forced-device contention; whole-lane envelope

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

* test(daemon): add real-scope runLocked serialization guard; whole-lane envelope duration

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

* test(daemon): allow seed 0 replay; add seed-0 regression (TORTURE_SEED must accept 0)

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

* obs(#1430): add scheduled-lane freshness/cadence health watcher

Discovers schedule: workflows from .github/workflows/, reads recent
scheduled runs via the GitHub API, and opens/pings a tracking issue when
a lane misses or fails two consecutive cadences. Pure model unit-tested
and gated on PRs; API I/O + issue open/ping run nightly.

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

* test(daemon): give scheduled-lane watcher a two-cadence newborn grace

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

* test(daemon): anchor lane grace on schedule-introduction, not workflow age

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

* test(daemon): derive schedule-activation semantically via git, through runCmdSync

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

* test(daemon): add merge-commit regression pinning first-parent + committer time

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

* test(daemon): pin scheduled-lane-health issue-write route via stubbed fetch

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

* test(daemon): unbundle #1430 watcher; make torture lane nightly-only

Strip the scheduled-lane-health watcher (scripts + workflow + PR gate) —
it is #1430's deliverable and collides with PR #1438's workflow of the
same filename; keep only this lane's #1430 envelope writer.

Move the torture lane under test/integration/nightly/ so it is out of the
test:integration:node glob, and run it via an explicit, disclosed PR step
plus the nightly sweep.

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

* docs: fix stale torture-lane paths after nightly/ move

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

* test(daemon): adopt shared lane-envelope for torture lane

Rebase onto main (post-#1441) and replace the lane-local LaneEnvelope
dialect with the shared scripts/lib/lane-envelope.ts builder, so the
#1430 health watcher parses one schema: commitSha->commit, sourceHash->
configHash, seedRange/runs moved into the typed data payload, and the
sweep encoded as seed "<start>-<end>".

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-28 10:29:41 +02:00
devin-ai-integration[bot] d747ef6230 test: frozen replay-compat corpus with expected verdicts (#1417) (#1436)
* test: frozen replay-compat corpus with expected verdicts (#1417)

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

* test: pin replay-compat corpus bytes to released blobs and assert via parseReplayInput

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

* test: lock replay-compat provenance kind by corpus area and verify it in CI

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

* docs: describe corpus provenance-kind lock and CI job

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

* test: prune replay-compat corpus to minimal witnesses per shipped form

Reviewer feedback on #1436: the mechanism earns its place, the dataset did not.
Drop the 30 corpus entries whose bytes repeat a syntactic form or a migration
refusal another entry already witnesses (platform twins and adjacent-release
re-recordings), leaving 22 deliberate entries; make note required and state per
entry which form or refusal it is the sole witness of.

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

* test: address corpus review nits (typed coverage list, cap rationale, derived-citation note)

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

* docs: split corpus rule — form from the release, verdict from today's parser

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

* docs: format corpus README emphasis markers

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-28 10:06:00 +02:00
Michał Pierzchała fcaa6c995c refactor(contracts): declare shared vocabulary below its consumers, ratchet what remains (#1435)
* refactor(contracts): declare the public API vocabulary below its consumers

The layering gate's largest remaining cluster was 28 type-only inversions from a
single edge: `commands/` declaring itself in terms of `client/client-types.ts`.
R2 forbids the reverse import, so a shape both surfaces need has to sit below
both. The command/device vocabulary — connection config, the device and session
views, and every per-command Options/Result — now lives in
`contracts/client-api.ts`; `client/client-types.ts` keeps the `AgentDeviceClient`
facade and re-exports the rest through one wildcard.

R6 total: 42 -> 18. No new inversion in any pair.

The published surface is unchanged, and that is verified rather than asserted:
the built `index.d.ts` exports the same 216 type names as main, byte-identical.

Eight shapes deliberately did NOT move, because each is stated in terms of a
HIGHER-ranked zone: `ScrollOptions` (ScrollInputDirection, commands/), the four
navigation Options plus `AgentDeviceCommandClient` (navigation-projection,
commands/), and the two Metro result aliases (metro/). Declaring those in
contracts/ would trade 28 commands->client edges for contracts->commands and
contracts->metro ones — the foundation depending on the layers above it, worse in
kind even though fewer in number. This is measured, not assumed: moving the whole
file to contracts/ first took the gate from 42 to 48, which is how the floor was
found.

Two keystone moves made the other 84 movable:

- `RemoteConnectionProfileFields` joined its sibling `CloudProviderProfileFields`
  in contracts/remote-config-fields.ts. It was the root of the base chain
  (AgentDeviceClientConfig -> AgentDeviceRequestOverrides ->
  DeviceCommandBaseOptions -> every per-command Options), so one rank-4
  declaration was pinning ~80 shapes up with it.
- `DaemonBatchStep` moved to contracts/batch-step.ts. Its `runtime` field was
  written `DaemonRequest['runtime']`, dragging the whole daemon request type in to
  say `SessionRuntimeHints` — the same type, three zones lower.

`CompanionTunnelScope`/`MetroBridgeScope` also moved to contracts/, since the
vocabulary needs the scope shape and it sat next to client-local env-var names.

Six pass-through re-exports in client-types.ts are suppressed per-name with the
reason inline: they exist only to publish contracts/kernel types through the
package entrypoint wildcard, every internal consumer imports them from the
declaring module, so "no consumer" is correct and not actionable — deleting them
would remove names from the public types.

`pnpm check` green, 4488 unit tests. Findings doc records the sequencing for the
last 5: the upstream declarations have to come down before the shapes that need
them can.

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

* docs: drop the graph viewer, keep the query that replaces it

The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.

This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.

Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.

Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.

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

* refactor(contracts): move the four keystones that pinned the rest of the inversions

R6 type-only spine inversions: 18 -> 7, and every one of the 7 that remains is a
deliberate architectural position rather than a misplaced declaration.

Four keystones moved to contracts/, each of which was pinning a much larger set:

- `CommandFlags` (was core/dispatch-context.ts). One rank-2 declaration holding the
  daemon's request type and every recorded action above it. Its last non-contracts
  dependency was `DaemonBatchStep`, already moved in 3fdbfe0.
- `SessionAction` (was daemon/types.ts). replay/ (6 modules) and compat/maestro/
  read and write session scripts; declaring the shape inside the daemon made both
  depend on the server to describe a file format neither asks it to produce. The
  daemon still owns the recording — only the shape moved.
- `TargetAnnotationV1` shape (was replay/target-identity.ts). ADR 0012 target
  evidence, written by 8 daemon modules and read by commands/; the parsing and
  classification logic stays in replay/.
- `ScrollInputDirection` and the Metro prepare/reload result payloads, which
  unblocked `ScrollOptions` and `MetroPrepareResult`/`MetroReloadResult`.

`DaemonRequest` also split into the three shapes it had been conflating: the
kernel WIRE shape (`flags?: Record<string, unknown>`, because a process boundary
cannot enforce a vocabulary), the new `contracts/command-request.ts`
`CommandRequest` (wire shape with flags typed — what a command surface needs), and
the daemon's own refinement (+ `internal?: DaemonRequestInternal`, carrying
SessionState callbacks and the admitted lease). core/command-descriptor/ had been
importing the third to read `command`, `positionals` and `flags`.

Two things deliberately NOT moved, because moving them would add coupling rather
than remove it, and the baseline now argues both:

- `DaemonCommandDescriptor`/`DaemonCommandRoute` — the route type is
  `keyof typeof DAEMON_ROUTE_HANDLERS`, derived from what the server implements.
  Moving it down means re-declaring route names in contracts plus a gate to prove
  the handler map still covers them. ADR 0003/0008 own that boundary.
- `AgentDeviceClient` — used as an opaque handle by 4 files. The facade is built
  from commands/'s own NAVIGATION_COMMAND_PROJECTIONS, so this is a genuine
  zone-level cycle; breaking it is a design call about where that registry belongs.
  R5 is zero here: nothing imports the client at runtime, only its type.

Also records the largest structural finding, which R6 does not measure: cycles by
edge kind are 1 (value only), 87 (value + type-only), 1 (value + dynamic), 213
(all). At runtime the graph is a clean DAG; the 87-file type-level cluster means
no one of those files' types can be read in isolation. Hubs are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts. Not attempted here — it is a different and much larger
change.

`pnpm check` green, 4488 unit tests.

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

* feat(layering): ratchet type-cycle growth (R9), and rule out a narrower client port

R9: the largest strongly-connected component over value + type-only edges may not
grow. R4 keeps the VALUE graph acyclic, so every cycle counted here is created by
type-only imports - free at runtime, invisible to R5/R6, and the largest single
obstacle to reading a subsystem in isolation: inside a component of 102 files, no
file has a self-contained slice.

Baseline set to 102, which is what THIS branch achieves - main carries 107 and the
boundary moves here bring it to 102. An earlier revision baselined 87, measured
against an older main; after rebasing onto f19864e the real figure was 102 and the
new rule fired on its own stale baseline. Worth stating because the failure looked
like a regression and was not: attribution showed main at 107 and this branch
reducing it, which is the check working rather than complaining.

Growth-only, deliberately unlike R6. Reducing 102 is a real refactor rather than a
file move, so a hard equality would turn every unrelated improvement into a baseline
edit. A shrunk tree is reported in the success line instead of failing. Verified at
the new baseline by adding one type-only import that closes a loop and watching 102
become 108 and the gate reject it.

The refactor itself is still not attempted. Hubs by in-component dependents are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts; a pass starts there.

Separately, investigated the narrower-port idea for the 4 remaining -> client
inversions and it does not work. Measured first:

  files NAMING AgentDeviceClient (the inversions)   4
  files CALLING client methods                     26
  distinct facade namespaces reached               13

The narrowness is an artifact of where the type is named, not of what is used.
Making those four generic over the client type pushes the concrete type into the 26
implementations, turning 4 inversions into up to 26. A port spanning 13 namespaces
is the whole facade, so it would either duplicate the public API shape - a second
source of truth for it - or derive from the facade and carry the same dependency.

So the four are the minimum number of naming sites rather than an accident: they are
the choke point. Recorded as a position with the numbers behind it. The remaining
option is the question underneath it - whether NAVIGATION_COMMAND_PROJECTIONS
belongs in commands/ - and that is a design decision about the command surface, not
a dependency cleanup.

pnpm check green, 4535 unit tests.

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

* fix(layering): test R9, specify its floor, and drop two duplications

Adversarial self-review of #1435 found four things worth fixing.

R9 shipped with no unit test. Every other rule in this gate has one (R5 back-edges,
R6 inversions, R7 session state, R8 zero-dep closures); R9's only verification was a
manual injection CI cannot repeat. Added tests for the three distinctions it depends
on: a type-only loop counts, a dynamic-only loop does not, a value loop still does.

Writing that test immediately found an undocumented edge case, which is the argument
for it. largestTypeCycleSize returns 1 for an acyclic graph that has non-dynamic
edges but 0 when every edge is dynamic, because only edge-participating files enter
the walk. Immaterial to a growth ratchet, but an inconsistent floor nobody had
written down. Now specified in the doc comment and pinned by the test, so 0 and 1
cannot later be read as a meaningful difference.

largestTypeCycleMembers was exported with no consumer - speculative API, and
scripts/layering is in Fallow's ignorePatterns so nothing would have flagged it.
Same pattern review caught on the previous head with MaestroRuntimeFlags and
TargetRect. Made module-private.

ResolvedMetroKind was declared twice after the Metro payload move: exported from
contracts/metro.ts and still private in metro/client-metro.ts. client-metro.ts now
imports it.

The gate computed the SCC twice per run, once in the rule and once for the success
line. Computed once and threaded, so the two can no longer disagree.

Also re-verified the claim this PR rests on, with a stronger check than the one in
the body: comparing DECLARATION names in index.d.ts counts inlined internals, and by
that measure this branch appears to lose five names (PrepareMetroRuntimeResult,
ReloadMetroResult, ResolvedMetroKind, SCROLL_INPUT_DIRECTIONS, ScrollInputDirection).
All five are declared-but-not-exported helpers. The real surface - exported names
across all eleven published entrypoints - is 69 on both sides, identical. Also proved
DaemonRequest structurally equal to its pre-split shape with a type-level assertion
rather than by reasoning, and confirmed SessionAction, CommandFlags and
TargetAnnotationV1 moved byte-identically.

pnpm check green, 4535 unit tests, 24 layering tests.

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

* refactor(contracts): one file per command family, one name per request

Addresses review on #1435.

contracts/client-api.ts was 1,064 LOC and grouped session, app, interaction,
replay, observability and recording contracts together, so it answered no one
question and crossed the >1,000-LOC architecture-debt tripwire in AGENTS.md:124.
Split it into 14 domain-family files by the command families that already exist
(client-connection, client-device-view, client-session, client-lease, client-app,
client-capture, client-target, client-gesture, client-selector-read,
client-replay, client-observability, client-settings, client-system,
client-request); the four Metro client shapes went into the existing
contracts/metro.ts so one file answers the Metro question. Largest resulting
file is 137 LOC. client/client-types.ts re-exports one wildcard per family, so
the published import path is unchanged.

Published surface verified unchanged against main two ways: the exported-name
set of all 11 published entrypoints is identical (70 names), and every
declaration in the built index.d.ts is byte-identical after normalization -- 0
names added, 0 shapes changed. index.d.ts got smaller (1,726 -> 1,682 lines):
10 declarations main duplicated into it now resolve through a shared chunk.

Also, from re-examining the two findings the review flagged as blind spots:

- CommandRequest was a third name for "a request" that no consumer needed.
  Every core/command-descriptor/ use read only command/positionals/flags, in two
  spellings (the full type and a Pick of it). Replaced by
  contracts/dispatched-command.ts DispatchedCommand -- those three fields and
  nothing else, with command/positionals Picked from the wire type so they
  cannot drift. daemon/types.ts DaemonRequest now extends the wire shape
  directly. Two request shapes again, at two ranks.
- The 7 remaining R6 inversions each get a mechanical reason rather than an
  appeal to an ADR: the 4 AgentDeviceClient edges are a real zone-level cycle
  (client-types.ts imports ProjectedNavigationCommandClient from commands/), and
  no narrower port exists (26 call sites across 13 namespaces); the 2
  DaemonCommandDescriptor edges are unavoidable because that shape is stated in
  terms of the server-private DaemonRequest; the 1 DaemonCommandRoute edge is
  unavoidable because the type is computed from the daemon's handler table.

Cleanups found on the way: three doc comments this branch had orphaned from
their declarations (SettleCommandOptions, RecordControlOptions,
ReloadMetroResult -- the last had drifted onto an unrelated type it
misdescribed) are reattached; intra-contracts imports normalized from
'../contracts/x.ts' to './x.ts', which is what the duplicate-import lint caught;
and stale references to the deleted file removed from the docs.

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 17:22:39 +02:00
Michał Pierzchała 2d1d70613f feat(bench): renderer-pinned samples, topic-coverage gate, error-recovery quizzes; trim skillgym to agentic checks (#1411)
* feat(bench): renderer-pinned samples, topic-coverage gate, error quizzes; trim skillgym to agentic checks

The help conformance bench's quoted CLI output is now sourced from
scripts/help-conformance-sample-outputs.mjs, and every sample is rebuilt
through the real production renderers (settle output formatters,
printHumanError, formatSnapshotText, refMutationAdmissionResponse) by
scripts/__tests__/help-conformance-sample-outputs.test.ts — a rendering or
message change fails deterministically instead of leaving the bench grading
against output the CLI no longer prints. This retires the fabricated
recoverable-failure envelope (production never throws a textual settle
timeout; that case is replaced by a real DEVICE_IN_USE recovery quiz).

Bench cases move to scripts/help-conformance-cases.mjs and are enumerated
against the help-topic registry: helpTopicIds() is exported from cli-help,
and scripts/__tests__/help-conformance-topic-coverage.test.ts fails when a
help topic has neither a bench case nor an explicit waiver. New case
families: error-envelope recovery quizzes (device-in-use, stale pinned ref,
ambiguous find match, app-not-installed) pinned to real error text, topic
coverage for tv/web/react-native/debugging/workflow, and a metamorphic twin
of the settled-diff quiz.

The skillgym smoke suite shrinks from 119 cases to the 5 that measure what
only an agentic runner can show: skill routing plus output interpretation
with a proven local CLI help probe (local-cli-help-policy). Its embedded
samples now import the same pinned constants, replacing hand-transcribed
output that had already drifted from the renderer. Knowledge checks belong
to the bench; live fixture behavior belongs to the iOS simulator e2e suite.

* review: drive error samples through the real producers; enforce local-help on the routing smoke

The DEVICE_IN_USE, AMBIGUOUS_MATCH, and APP_NOT_INSTALLED parity tests no
longer hand-author the producer message before rendering: each drives the
actual producer — buildDeviceInUseBySessionError (extracted in
session-open.ts and called by the handler), buildAmbiguousMatchError (now
exported from find.ts), and buildAppNotInstalledError (extracted in
app-resolution.ts and thrown by the resolver). Because each factory is
exported from its producer file and called by the production path, dropping
the production call would make it test-only and fail
check:production-exports — the wiring is gate-enforced, not conventional.

open-and-snapshot now sets requireLocalCliHelp and
allowOnlyLocalCliHelpCommands, so the 'skill plus local help' claim is
observed rather than assumed; without them the case can pass on model prior
alone.
2026-07-27 14:24:36 +02:00
Michał Pierzchała ab913c9720 feat: strengthen agent help benchmarks (#1404)
* feat: strengthen agent help benchmarks

* fix: harden help benchmark review findings

* fix: close help benchmark validation bypasses

* fix: make selector scoring quote-insensitive
2026-07-27 10:17:46 +02:00
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