Commit Graph

1156 Commits

Author SHA1 Message Date
Claude 2c519cd562 experiment: analysis-only depgraph, to test whether it clears the gate unexempted
NOT a finished change — pushed so the comparison behind a pending decision is
reproducible rather than a claim in a chat log. PR #1409 still carries the viewer.

Question: the viewer's productive output turned out to be the JSON, not the
render. Every finding this session came from numeric queries; the render was never
opened to make a decision. So: does an analysis-only version meet the repo's bar
WITHOUT the Fallow exemption that PR #1409 needs?

Method: delete viewer.{js,css,html} and the geometry (clusterLayout,
layeredLayout), keep computeLevels (it is analysis, not layout), emit JSON plus the
text summary, and REMOVE scripts/depgraph/** from ignorePatterns.

Result, with zero exemptions:

                        with viewer   analysis only
  complexity findings   23 (1 CRIT)   2
  unused files          2             0
  unused exports        3             0
  clone groups          1             0
  lines                 2811          ~590

Identical output: 898 files, 4627 edges, 1338 redundant value edges, 8 non-gated
cycles, R6 42 (matching the gate's baseline). So the numeric part can meet the
repo's bar unexempted; viewer.js — 920 lines with a CRITICAL-complexity `draw` —
never could.

Fixed along the way rather than suppressed: extracted `valueSuccessors` (the
value-edge adjacency was built identically in markRedundantEdges and
computeLevels — a real clone), extracted `edgeKindCode`/`edgeFlags` from a nested
ternary with CRAP 42, un-exported buildPayload/main, and deleted `fileGroup` and
the `group` node field, both dead once the cluster layout went.

Still open if this direction is chosen: split `buildGraph` (81 lines, 20
cyclomatic) and `markRedundantEdges` — the last 2 complexity findings, ordinary
functions rather than a canvas renderer. README is rewritten to match the
report-only shape; the when-to-use guidance carries over unchanged, since it was
already about numeric queries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
2026-07-27 08:34:23 +00:00
Claude cd19a73c55 feat(scripts): interactive dependency-graph viewer, with when-to-use guidance
Renders every production file under src/ as a pannable graph in one self-contained
HTML file — no external requests, no runtime dependency, layouts precomputed at
build time so the viewer never runs a physics simulation on a phone.

  pnpm depgraph          # -> .tmp/depgraph/index.html (+ index.json)
  pnpm depgraph:test

It reuses the layering gate's model (`listSourceFiles`, `resolveImportEdges`,
`zoneRank`) rather than extracting its own graph. That matters more than it
sounds: a separate extractor with its own resolution behaviour would draw a graph
nobody enforces. Because the model is shared, its R6 count reproduces
TYPE_INVERSION_BASELINE exactly, which doubles as a self-check.

The README now documents WHEN it is productive, because the honest answer is
"for three questions, and it misleads on a fourth":

- what am I about to break (dependent counts, including the type-only and dynamic
  edges a grep for `from '...'` misses);
- where is the debt concentrated (zone-level counts);
- what is wrong that CI does not enforce — ~1300 transitively redundant value
  edges and 8 type-only/dynamic cycles, both outside the gate by design.

The fourth: a cluster's SIZE IS NOT ITS DIFFICULTY. `commands -> client` looked
like the obvious win at 28 edges into one file; moving that file down took the
gate from 42 to 48, because the vocabulary it holds depends on commands/, metro/,
core/ and remote/. The render shows an edge's weight, not whether it can be
reversed — so the README pairs every visual question with the numeric query that
answers "can this actually move?", verified against the real output rather than
written from memory.

Also states plainly that `pnpm check:layering` is authoritative and nothing here
gates a merge: it is an instrument, not a rule.

scripts/depgraph/** joins scripts/layering/**, scripts/perf/** and
scripts/maestro-conformance/** in Fallow's ignorePatterns, which is how this repo
already treats tooling trees. Worth knowing rather than discovering: that exempts
viewer.js from the complexity gate, and its `draw` function would fail it.

Two exports added to scripts/layering/model.ts: `zoneRank` (the viewer colours
nodes by rank, so an inversion reads as an edge pointing the wrong way down the
ramp) and `targetDagZone`, previously module-private.

`pnpm check` green, 4488 unit tests. Verified against current main: 898 files,
4627 edges, 25 zones, R6 count matching the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
2026-07-27 06:54:46 +00: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 152894cce2 docs(adr): rules-first ADR restructure + ADR 0017 proposal (unified event journal) (#1399)
* docs(adr): rules-first restructure of 0012/0014/0016, drop completed migration logs

ADR 0012 alone was 42% of the ADR corpus by bytes; consulting it cost ~28k
tokens of mostly process history. Restructure per the new shape convention
(added to the ADR README): Status + a normative 'Rules at a glance' first so
a reader can stop after ~50 lines, rationale and refuted alternatives kept
below the fold, and completed migration plans/landing tables deleted — git
history is the archive.

- 0012: delete migration plan/progress; fix the Status section that still
  claimed #1235 unimplemented against its own landing table; demote the
  2026-07-10 evidence audit to the end (still cited by the decisions).
- 0014: same; the accepted Android blocking-dialog-recovery evidence gap and
  its covering fixture tests move into Status so the waiver survives.
- 0016: verified implemented; rules summary added (nothing was history).

No rule's meaning changed; edits are reorganization plus stale-status fixes.

* docs(adr): propose ADR 0017 — unified request event journal

Apply ADR 0008's registry thesis to events. Inventory (2026-07-24) found four
parallel event vocabularies — ~155 stringly-typed diagnostics phases, the
session events.ndjson, the progress wire stream, and the replay timing trace
(one of its two writers unredacted) — with consumers coupled to emit sites by
string: agent-cost counts runner round-trips by matching two phase names.

Proposal: an EVENT_CATALOG in contracts making every kind a typed,
trait-carrying declaration; the diagnostics scope becomes the single journal
append point; every consumer becomes an explicitly registered sink; all
existing file/wire formats stay byte-compatible behind golden fixtures.
Explicitly rejects pub-sub and event sourcing. Status: Proposed — not indexed
in the ADR README until accepted.

* docs(adr): revise ADR 0017 per architecture review

Address all five review findings and adopt both requested judgments:

- P1 out-of-request events: finalizeRepairTeardown records a synthesized
  close during idle-reap/daemon-shutdown with no live request; a
  request-scoped-only journal would silently drop it. Added an explicit
  session-scoped teardown scope model (fatal-scope precedent) and rejected
  the ambient-fallback alternative.
- P1 redaction vs byte-compat: progress stays unredacted on its own channel;
  the replay-trace unredacted->redacted change is now a declared, intentional
  compatibility change with its own fixture update, not smuggled under a
  byte-compat claim.
- P1 per-attempt trace routing: sinks with dynamic destinations read
  scope-bound routing context (logPath-rebind precedent); drop-when-unbound
  semantics; sink ordering/isolation/flush contract made normative.
- P2 progress typing: progress streaming removed from the journal entirely -
  it is a transport-owned output port (ordering, disconnect-as-cancellation,
  closed typed union); mirror emits noted as the future opt-in shape.
- P2 completeness check: orphan detection is now a static source scan in the
  layering-lint style; runtime unit-suite observation explicitly rejected.

Also per review: catalog keys are internal identities; sinks map to legacy
wire discriminators, which are never automatically canonical. Migration plan
reduced to 4 steps.

* docs(adr): ADR 0017 — fork, never rebind, for per-attempt trace routing

Review found a blocking concurrency flaw in the revised routing design:
sharded test attempts run concurrently (Promise.allSettled in
runReplayTestShards) under one inherited AsyncLocalStorage request scope, so
mutable scope rebinding would let one attempt overwrite or clear another's
replay-timing destination after an await — cross-writing or dropping events.

Replace rebinding with a journal fork primitive: journal.fork(bindings, fn)
runs fn in a new ALS scope object sharing the parent's buffer/phaseCounts/
envelope/sinks but carrying frozen routing bindings. Each attempt wraps its
work (including nested replay dispatch) in a fork binding its own trace
path; the binding dies with the fork, so no clearing step exists to race.
Existing updateDiagnosticsScope rebinds stay confined to sequential request
setup, pre-fan-out. Validation gains a concurrent-shard regression proving
each replay-timing.ndjson contains only its own attempt's events.

* docs(adr): ADR 0017 — scope identity on the envelope for future exporter sinks

Reserve the one shape decision an OTel-style exporter would otherwise force
a retrofit for: every scope (request, teardown, fork) carries scopeId, forks
record parentScopeId, both ride the event envelope. Forks already form a
tree, so an exporter sink can emit parent-child spans from envelope fields
alone. Cross-process correlation stays requestId; a traceparent-style meta
field is additive under ADR 0006 and deferred. No exporter in this ADR.

* docs(adr): address re-review — renumber to 0018, full fork isolation, 0016 record-as

1. Renumber the proposal 0017 -> 0018: main now carries accepted ADR 0017
   (parameterized recorded inputs, #1369); branch rebased onto it.
2. Fork contract strengthened: forks clone EVERY mutable scope field
   (envelope, logPath, routing bindings) and own their event buffer; only
   the sink list and the request-global phaseCounts tally are shared.
   Verified in code: nested dispatch creates child execution scopes
   (request-router.ts:257) whose updateDiagnosticsScope rebinds
   session/logPath mid-flight, so a shared mutable envelope would
   cross-route debug/session-log events between concurrent shards even with
   frozen trace bindings. updateDiagnosticsScope now specified as mutating
   only the innermost scope. The regression now covers all three routed
   outputs (replay-timing, per-request diagnostics ndjson, events.ndjson).
3. 0016 rules summary updated for shipped #1348: sensitive fills use
   fill --record-as <VAR> (ADR 0017); unparameterized fill/type stays
   literal (body sections already updated by #1369's merge).

* docs(adr): ADR 0018 — name the usage sink as first consumer, privacy by construction

The motivating consumer is opt-in usage analytics over agent behavior:
command frequencies, typed failure codes, and outcome sequences that trip
agents (consecutive snapshots, screenshot-after-snapshot). Decision 4 pins
its discipline now, before any exporter exists: an allowlist-by-construction
UsageRecord schema whose every field draws from a registry-enumerated
vocabulary (command names, ADR 0010 error codes, flag names, durations,
hashed session + sequence number) — positionals, selectors, labels, fill
text, and error messages are unrepresentable by type, not redacted.
Anti-pattern detection is downstream analysis over the stream, never
emission-side logic; the sink itself is a follow-up after migration step 3.
Adds the matching invariant and a schema gate to validation.
2026-07-25 14:03:03 +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
Andrew Barnes 14be01b781 fix(replay): preserve cwd scope for opened sessions (#1401)
Co-authored-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com>
2026-07-25 12:17:54 +02:00
Michał Pierzchała 1a76344685 docs: restructure AGENTS.md and CONTEXT.md for progressive disclosure (#1402)
* docs: restructure AGENTS.md and CONTEXT.md for progressive disclosure

Apply the Claude 5 context-engineering guidance to the repo's agent docs:
keep the always-loaded file to gotchas and invariants, and move situational
guidance one hop away behind a routing table.

AGENTS.md 315 -> 229 lines. Cut generic agent-behavior boilerplate, three-way
duplication (Common Mistakes restated Hard Rules; Finding Source Owners
restated the registry section), and facts visible from the repo itself.
Kept verbatim: the expensive-lessons principles, enforcement gates, Hard
Rules, and environment traps.

Split out docs/agents/{cli-flags,pull-requests,device-verification}.md and
folded the Testing Matrix into docs/agents/testing.md, reframed around
pnpm check:affected so the prose stops duplicating the selector.

CONTEXT.md keeps all 50 terms, now grouped under a section index so a task
loads one section instead of the whole glossary.

* fix(check-affected): move the selector-owning sentinel to the Testing Matrix

The Testing Matrix moved from AGENTS.md to docs/agents/testing.md, but the
affected-check selector still treated only AGENTS.md as selector-owning. A
later matrix edit would have been classified as inert docs and skipped the
fail-open, so the selector could keep deriving gates from a spec that had
changed underneath it.

Move the sentinel with the prose, as a named SELECTOR_OWNING_DOCS set so the
next move is one line, and fix the two in-code comments plus the testing.md
paragraph that still pointed at the AGENTS.md matrix.

* docs: restore two rules dropped by the AGENTS.md split

Review caught two repo-specific rules that did not survive the move. Both are
prose without any backticked identifier, so the identifier-diff used to verify
the split could not see them.

- "Test through public interfaces; do not add unrelated production exports
  solely to enable tests" returns next to the behavioral-tests rule in
  docs/agents/testing.md, with the reason it exists.
- The guidance-ownership rule (decide whether new guidance/schema/metadata
  belongs to the command surface, CLI grammar, CLI help, MCP projection, or
  daemon runtime) returns to the always-loaded Docs & skills section, since it
  governs all command-surface work and not just the flag case.

Also point the ADR routing row at docs/adr/README.md, which is already the
"read when you touch…" index, rather than at the bare directory.
2026-07-25 12:13:09 +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 11e0a1f187 feat: add WebView accessibility lab (#1397)
* feat: add WebView accessibility lab

* refactor: tighten iOS snapshot presentation rules

* fix: preserve semantic WebView containers
2026-07-25 11:40:27 +02:00
Michał Pierzchała 5507a08b9c feat: parameterize sensitive recorded inputs (#1369)
* feat: parameterize recorded inputs

* fix: harden parameterized replay recording

* fix: sanitize parameterized fill echoes

* fix: scrub embedded parameterized fill echoes

* fix: make recorded fill scrubbing idempotent

* fix: replay parameterized coordinate fills

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

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

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

Fixes #1384

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

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

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

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

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

Addresses the second review pass on #1390:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1380

* style: run pnpm format

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

* fix: address static check failures from review
2026-07-24 12:54:52 +02:00
Michał Pierzchała a0ab735ffd fix(interaction): direction-named, selector-first off-screen recovery hints (#1366) (#1374)
* fix(interaction): direction-named, selector-first off-screen recovery hints (#1366)

An agent that hits a scrollable form section gets correctly rejected for
targeting an off-screen element, but the recovery hint didn't name a
concrete next move. Its "obvious" retry — re-issue the same @ref after
scrolling — is exactly what both live guards reject: the off-screen guard
still sees it off-screen, and the scroll expired the ref frame (ADR 0014),
so the @ref is refused as stale. Two bootstrap-bench runs burned all 60
turns on a single checkout-form screen this way (arm-independent).

Make the rejection self-sufficient for recovery instead of relaxing either
guard:

- Off-screen selector/ref rejection now names the exact scroll direction
  (computed from the target rect vs its effective viewport) and steers the
  retry to a *selector*, which re-resolves against a fresh snapshot and
  bypasses the ref-frame admission guard entirely. The direction is also
  surfaced as a machine-readable `scrollDirection` detail.
- `scroll`'s unknown-direction error now carries a grammar hint, since the
  transcripts show agents mis-shaping it as `scroll @ref down` — scroll
  takes a direction and no target.

New geometry helper `classifyOffscreenScrollDirection` maps a rect +
viewport to the reveal direction (largest-overshoot axis), following the
same convention the CLI off-screen summary already uses.

Both guards keep their exact rejection semantics and messages; only the
hints and one added detail field change.

* fix(interaction): derive scroll direction from the boundary that rejected (#1366 review)

Address P1: the direction classifier used a rect-vs-single-viewport test, so it
only fired when the whole rect was fully past an effective-viewport edge. But
`isNodeVisibleOnScreen` rejects on two boundaries — and the second one (tap-point
CENTER outside the ROOT viewport while the rect still overlaps its container) is
exactly the off-screen-drawer / edge-straddling case #1366 is about. Those got
the generic hint and no scrollDirection, leaving the loop unresolved.

`classifyOffscreenScrollDirection` now takes (node, nodes) and mirrors both
rejection boundaries: full separation from the effective viewport, then a
center pushed outside the root viewport. Direction is taken from whichever
boundary failed, using the same unrounded center as the tap-point rule, so a
rejected target always yields a direction.

Regressions: partial clip whose center is past the viewport edge (both the pure
classifier and the live selector-press rejection path), and a child inside an
off-screen scrollable ancestor (closed drawer).

* style: oxfmt the windowRoot test helper (#1366 CI)

* fix(interaction): bounded off-screen recovery hint — a large scroll overshoots (#1366 review)

Live evidence exposed that the hint over-promised: it advertised `scroll
<direction>` -> retry-selector as the terminating recovery, but on the
motivating checkout form a plain `scroll` (iOS fling momentum) repeatedly
overshoots the narrow pressable band and the loop never terminates — even
bounded `scroll --pixels 200` oscillates up/down. A momentum-free `gesture pan`
in small steps lands the target (verified: 3–4 attempts -> Tapped "Cash").

Revise the hint to prescribe BOUNDED movement in the named direction, retrying
the same selector after each step, and to name the overshoot failure mode and
the reliable `gesture pan` fallback. Direction + selector-first steering are
unchanged. `scrollRevealClause` drops the "to bring it on-screen" over-promise.

Tests assert the bounded-recovery guidance (small steps / gesture pan) on both a
selector and a ref off-screen path so it can't silently regress.
2026-07-24 08:17:57 +02:00
Michał Pierzchała d45190613c fix: report bundle sizes with two decimal MB precision (#1382) 2026-07-24 08:17:42 +02:00
Szymon Dziedzic 3faeb97855 feat(events): enrich session event details (#1379)
* feat(events): enrich session event details

* fix(events): harden session event projections

* fix(events): sanitize provider-derived metadata

* fix(events): close remaining projection gaps
2026-07-24 08:17:23 +02:00
Michał Pierzchała 67d5d21cf8 fix(android): preserve fixed siblings after snapshot filtering (#1378) 2026-07-23 16:08:02 +02:00
Michał Pierzchała 5cea83d994 fix: select iOS simulator by installed app (#1376)
* fix: select iOS simulator by installed app

* fix: preserve app-aware open selection
2026-07-23 16:03:33 +02:00
Michał Pierzchała 00ef28734f refactor(commands): derive pass-through command bindings with a generic BoundOf helper (#1368)
The five fully pass-through families (capture, system, admin, recording,
observability) hand-wrote 'key: (options) => x.key(runtime, options)' per
command plus a Bound* type mirroring RuntimeCommand -> BoundRuntimeCommand
field-by-field. One mapped type + one generic binder now derives both.
BoundOf keeps the optional-parameter ergonomics for commands whose options
include undefined, so call sites like commands.back() are unchanged.

bindAppCommands stays as a spread + manual 'list' override (filter
normalization); selector/interaction families stay hand-written since most
of their entries reshape signatures (prepended target/text positionals).
2026-07-23 10:06:32 +02:00
Michał Pierzchała 0b0deb3b9e refactor(interaction): resolve the CapturedSnapshot name collision (#1371)
resolution.ts and selector-read-shared.ts (both in
src/commands/interaction/runtime/) each exported an unrelated
CapturedSnapshot type with a different shape. resolution.ts's export
is never imported anywhere else, so rename it to InteractionSnapshot
rather than force a merge with the richer, session-carrying shape
that selector-read.ts/settle.ts/stable-capture.ts actually depend on.
2026-07-23 09:57:44 +02:00
Michał Pierzchała 9a61ac71a1 refactor(cli): decompose runCli into explicit phase functions (#1373)
runCli inlined parse/help short-circuits, binding resolution, remote
auth + materialization, four special-cased command kinds, dispatch, and
a catch block coupled to ~10 closure-mutated let bindings across ~390
lines. Each phase is now a named function over an explicit CliRunContext:
parseCliInputOrExit, resolveRunContextOrExit, runReactDevtoolsCli,
resolveRemoteContext, buildClientConfig, maybeStartDaemonLogTail,
createReplayReporterForTest, dispatchCliCommand, handleRunCliFailure.

The context is mutated in place by resolveRemoteContext so the failure
handler observes exactly the state the throwing phase saw — matching the
previous closure semantics, including the close-with-no-daemon success
path and daemon-log-tail-on-error. Behavior is unchanged; runCli itself
is now ~75 lines of orchestration.
2026-07-23 09:57:02 +02:00
Michał Pierzchała 19cea66c8b chore(deps): resolve dependabot alerts (#1372)
Pin transitive dependencies past their vulnerable ranges via pnpm
overrides, scoped to the affected major so unrelated majors elsewhere
in the tree stay untouched:

- undici 7.24.7 -> 7.28.0 (root/website): @limrun/api pins undici to
  an exact version even in its latest release (0.44.0), so bumping the
  direct dependency can't fix this; override the transitive resolution
  instead.
- shell-quote 1.8.4 -> 1.10.0 (examples/test-app)
- js-yaml 4.1.1 -> 4.3.0 (examples/test-app)
- brace-expansion 5.0.6 -> 5.0.7 (examples/test-app)
- @babel/core 7.29.0 -> 7.29.7 (examples/test-app)
- ws 7.5.10 -> 7.5.13 (examples/test-app)

Resolves all 13 open Dependabot alerts (7 high, 3 moderate, 3 low).
2026-07-23 09:56:24 +02:00
Michał Pierzchała d9b583b950 test: serialize client-metro and harden the unit suite against ambient daemon env (#1365)
* test: serialize client-metro subprocess-stub test to end contention flakes

src/__tests__/client-metro.test.ts stubs npx and the package managers on
PATH and spawns a real Metro dev server per case, so each case waits real
subprocess time (~570-910ms measured). Run in the unit-core project at ~7x
file parallelism it contends for CPU with every other stub-spawning file; a
starved spawn pushes production down a generic failure path that returns a
different error than the assertion expects. This is the same contention flake
#1362 serialized runtime-hints.test.ts and apple/core/index.test.ts for, but
this file was not included in that batch (observed failing a full test:unit
run while passing 20/20 in isolation and green on a re-run).

Add it to SUBPROCESS_STUB_TESTS so it joins the fileParallelism:false,
maxWorkers:1 subprocess-stub project, so at most one real-stub-spawning file
spawns at a time.

Injecting the spawn budget is not the lever here: the fake dev server becomes
ready fast (no waited-out timeout to inject), the per-case cost is a genuine
subprocess spawn, and each case is already under the 2.5s slow-test budget so
the gate never flags it. Removing the cost would mean mocking the very
spawn/args/package-manager-detection path the file exists to verify, and an
exec-options DI seam is forbidden by the CI gate (AGENTS.md).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGCchHyrPhTqtXniWuERYK

* test: make the vitest suite hermetic against ambient daemon env

A machine actually running agent-device — including this repo's own remote dev
containers — exports AGENT_DEVICE_DAEMON_BASE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN
pointing at a live daemon. Production flag-default resolution folds those into
every command's input and connection config (resolveConfigBackedFlagDefaults ->
readEnvFlagDefaults, plus the daemon client's own env fallbacks in
daemon-client-lifecycle), so a configured host silently diverges from CI, which
runs with them unset:

- command-tools and cloud-connect-profile assert exact command input / profile
  shapes and gain phantom daemonBaseUrl/daemonAuthToken keys.
- daemon-client and daemon-client-lifecycle take the remote-daemon path
  ("Remote daemon is unavailable") instead of the local one they exercise.

28 tests across those four files fail deterministically on such a host — fast,
in isolation, not contention — while passing on CI.

Add a shared setup file that deletes the two ambient daemon-connection vars
before each test file, wired into every vitest project alongside the existing
process-memo reset, so a configured host matches CI. Tests that need these set
assign their own value or pass an explicit env object, which runs after this
setup module loads and is therefore unaffected.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGCchHyrPhTqtXniWuERYK

* test: add a regression guard for the hermetic-env setup

CI runs with AGENT_DEVICE_DAEMON_* unset, so it can never exercise the scrub
that hermetic-env-setup.ts performs — deleting the setup file or forgetting to
wire a project would leave the whole suite green. Add a guard that closes that
gap two ways:

- A static check that every configured vitest project lists the setup file in
  its setupFiles (catches an unwired project).
- A real vitest child, launched with both daemon vars set, running a probe
  fixture that proves a wired project sees them scrubbed — plus a negative
  control (same vars, setup disabled) proving the probe actually detects the
  leak, so a green result is the setup working, not a no-op. A control var the
  setup must not touch proves the child inherited the injected environment. The
  two children run concurrently to stay within the unit wall-clock budget, and
  the file joins the serialized subprocess-stub project since it spawns real
  vitest processes.

Ignore the fixture config's default export in fallow — vitest loads it by path
rather than importing it, the same class as the other tool-config default
exports already listed there.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGCchHyrPhTqtXniWuERYK

* fix(test): route the hermetic-env guard's vitest child through runCmd

AGENTS.md hard rule: TypeScript process execution must go through
src/utils/exec.ts, never raw spawn/spawnSync. The guard's runProbe spawned the
vitest child with node:child_process spawn directly, bypassing the shared
timeout, process-tree cleanup, normalized failure, and diagnostics behavior.

Replace it with runCmd invoking the repo-local vitest CLI
(node node_modules/vitest/vitest.mjs) with allowFailure: true and a bounded
timeoutMs, asserting the returned exitCode. allowFailure surfaces the negative
control's non-zero exit as data instead of throwing, so that assertion keeps
working; the two probes still run concurrently to stay under the unit budget.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGCchHyrPhTqtXniWuERYK

* fix(test): make the hermetic-env guard's probe cleanup timely and complete

Follow-up to review. Two process-tree cleanup gaps in the guard's runProbe:

- It gave the nested vitest a 60s timeout while the enclosing test used vitest's
  default (5s) timeout, so a hung probe would time the parent test out first and
  leave the child running (orphaned).
- runCmd only process-group-kills when detached:true; without it a timeout kills
  only the direct child, so vitest worker descendants could survive.

Run the probe detached so a timeout kills the whole process group (the vitest
child and its workers), and set the child timeout (20s) strictly below an
explicit enclosing test timeout (40s) so a hang is reaped by runCmd before
vitest abandons the parent test. The repo-local node/vitest invocation,
allowFailure, exit-code assertions, and the concurrent positive/negative probes
are unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGCchHyrPhTqtXniWuERYK

* test: replace the child-process hermetic-env guard with an in-process test

The previous guard spawned a real vitest child to exercise the setup through
vitest's setupFiles machinery, which dragged in process-group cleanup, timeout
ordering, a fixture config + probe, and a fallow suppression — a pile of
scaffolding around a scrub that is two delete statements.

Prove the same contract in-process instead:
- Behavior: set the daemon vars, vi.resetModules(), re-import hermetic-env-setup,
  and assert they are gone. This exercises the real import-time scrub on any host
  (CI included, where the vars are otherwise absent) and fails if it regresses.
- Wiring: assert every configured vitest project lists the setup in setupFiles.

Runs in ~5ms in unit-core with no subprocess, so it also drops out of
SUBPROCESS_STUB_TESTS and removes the fixtures and the fallow ignore entry.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGCchHyrPhTqtXniWuERYK

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 09:55:57 +02:00
Michał Pierzchała d237bc555d chore: remove verified dead code and migration scaffolding (~700 LOC) (#1367)
* chore: remove verified dead code and migration scaffolding

Multi-agent audit of accumulated waste, every finding adversarially
verified against call sites, git history, and the published surface
before removal. Net -710 lines.

- delete src/core/platform-descriptor/ (superseded ADR-0009 migration
  scaffold; parity tests now assert an inline table)
- remove test-only seams: registry introspection exports,
  CommandFacet.extraDaemonWriters, MaestroEngineOptions.timing
- remove dead flexibility: backend capability allow-list,
  screenshot-diff maxRegions, CloudWebDriverSupportLevel 'partial',
  clearFirst on the TS+Swift runner wire contract
- remove dead deprecated surface: --session-locked /
  --session-lock-conflicts aliases (hard migration error now points at
  --session-lock), replay export --format single-value enum,
  unused Lease*Payload contract types, runtime-layer rotate duplicate
- collapse pass-throughs/duplication: withRetry adapter,
  default-cloud-artifact-provider, connect-profile client-id hashing
  (3x sha256 impls -> one helper, byte-identical output), shared
  scripts walker, cloneValue -> structuredClone, fill-diagnostics
  moved into android/

BREAKING CHANGE: --session-locked and --session-lock-conflicts now fail
with a migration error pointing at --session-lock; replay export
--format is removed (Maestro was the only value); Lease*Payload types
are dropped from the ./contracts subpath.

* chore: satisfy fallow gates tightened by #1363/#1364 after rebase

- drop the consumer-less AndroidFillVerificationNode re-export
- reuse requireSnapshotSession in resolveSnapshotForRef instead of
  inlining the same authorized-frame resolution (fallow clone group);
  the helper's return type now guarantees the session it already
  throws for

* chore: address review — keep cloud-webdriver partial capability metadata

The partial/supported/unsupported levels and their notes are part of the
lease-response capability contract for genuinely limited operations
(Appium page-source snapshots, upload-then-install), not dead
scaffolding. Restore them and the asserting tests unchanged from main.

Also add the missing CHANGELOG entry for the Lease*Payload type removal
from agent-device/contracts.
2026-07-23 07:36:52 +02:00
Michał Pierzchała 317469f35a chore: remove dead type re-exports, prune stale suppressions, gate unused-types (#1364)
* chore: remove dead type re-exports and gate unused-types

Clears the 72 `unused-types` findings left by #1363 and flips
`rules.unused-types` from `warn` to `error` so new ones cannot creep in.
Every fallow detector is now at zero.

71 of the findings were re-export lines (`export type { X } from './y.ts'`
with no importer of X through that path); one was a direct declaration
(`HelpTopicName`, zero references anywhere).

Removing them cascaded, which is the point: imports that existed only to
feed a re-export became unused (8 of them, caught by `noUnusedLocals`), and
clearing those exposed a further chain through `command-projection.ts` ->
`batch/index.ts` -> `batch/projection.ts`. Also updated a comment in
`batch-policy.ts` that described the `command-surface.ts` re-export path
this removes, and dropped two `export type {}` husks left where every name
in a block was dead.

One finding was NOT what it looked like: fallow flagged `DebugSymbolsOptions`
and `DebugSymbolsResult` in `apple/core/debug-symbols/types.ts`, but removing
them broke `apple/core/debug-symbols.ts`, which re-exported them from there.
Both ends were dead — every real consumer imports from
`contracts/debug-symbols.ts` — so the fix was removing the intermediate
re-export too, not restoring the leaf.

Safety: the published type surface is unchanged. All 11 entry points in
package.json#exports export exactly the same names before and after. Two
chunk files differ only because `AndroidSnapshotBackendMetadata` relocated
between internal code-splitting chunks, which is not a consumer-visible
boundary.

* chore: prune fallow suppressions that no longer suppress anything

The whole point of this arc was that fallow looked clean mainly because of
its own ignore list, so the list itself deserved an audit. Emptying
`ignoreExports` and re-running shows most entries no longer match a real
finding: 20 blocks collapse to 5, with no change in what either fallow
invocation reports.

Removed 8 fully-stale blocks. They went stale for three different reasons:
- code moved (`assertSafeDerivedCleanup` is exported from runner-cache.ts,
  not the runner-xctestrun.ts the suppression named),
- the exports became genuinely used (apps.ts, runner-contract.ts,
  runner-session.ts, cloud-webdriver.ts, the test-utils fixtures),
- and `installAndroidInstallablePath` was suppressed in two places at once.

`app-lifecycle.ts` needed a code fix rather than a suppression: it
re-exported three `parseAndroid*` helpers from app-parsers.ts that nothing
imports through it, so the re-export is dropped and the block goes away.
The two type re-exports on that statement ARE consumed and stay.

The seven `src/daemon/handlers/*` blocks are KEPT, collapsed into one
documented glob. They are not stale: those handlers are reached only
through the dynamic `import()` table in request-handler-chain.ts, which the
--production analysis behind `check:production-exports` cannot follow. A
first pass removed them because the staleness probe only exercised the
default config; the repo runs fallow twice, and `check:production-exports`
caught it.
2026-07-22 18:17:59 +02:00
Michał Pierzchała c0fc822e80 chore: remove dead code and tune fallow's dead-code rules (#1363)
Evaluated knip (webpro-nl/knip) against the fallow setup already in the
repo, cleaned up everything it surfaced, then removed knip again: measured
head-to-head on the same tree, fallow is a strict superset once two
switches it already supports are flipped.

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

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

`unused-types` stays at `warn`: 72 pre-existing type re-export lines across
27 files remain, tracked separately. Every other fallow detector is at zero.
2026-07-22 17:16:02 +02:00
Michał Pierzchała 074ca140a2 test: serialize PATH-stub daemon/apple tests to end contention flakes (#1362)
`src/daemon/__tests__/runtime-hints.test.ts` and
`src/platforms/apple/core/__tests__/index.test.ts` inject stub `adb`/`xcrun`
binaries by mutating `process.env.PATH` and then spawn them, so each case
waits real subprocess time. Run in the `unit-core` project at ~7x file
parallelism they contend for CPU with every other stub-spawning file; a
starved stub spawn pushes production down a generic failure path that returns
a different error than the assertion expects. The failures present as
assertion errors (not timeouts) and the failing subset shifts between runs —
a pre-existing contention flake, reproducible on a clean tree.

The repo already serializes the android scripted-adb tests into their own
`fileParallelism: false, maxWorkers: 1` project for exactly this reason. This
generalizes that project (renamed `android-adb` -> `subprocess-stub`) and adds
the two files, so at most one real-stub-spawning file runs at a time across
the whole suite. Assertions no longer let host wall-clock decide which error
path runs.

Option of injecting the spawn budget instead was not viable: runtime-hints
passes no timeout to inject, and adding an exec-options seam purely for the
test would be a test-only DI seam the CI gate forbids (AGENTS.md).


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-22 17:13:50 +02:00
devin-ai-integration[bot] 611c7ed03d fix(apple): parse parenthesized xctrace physical device format (#1360)
* fix(apple): parse parenthesized xctrace physical device format

Accept the new 'Device Name (OS Version) (device-id)' output from xctrace list devices while preserving the legacy bracket format.

Relates to #1355

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

* refactor(apple): split xctrace parser to reduce cyclomatic complexity

Extract device line parsing and DeviceInfo construction into focused helpers so the discovery loop stays under fallow thresholds.

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

* fix(apple): avoid interpreting bracket-format device names as having OS version

Only treat 'Name (version) (id)' as the new parenthesized xctrace format; preserve the full name for legacy 'Name [id]' lines, including names that contain parentheses.

Addresses review feedback in #1360.

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

* test(apple): add route-specific xctrace fallback test

When devicectl reports no devices, listAppleDevices must source the physical device from the parenthesized xctrace output.

Addresses review feedback in #1360.

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-22 17:13:37 +02:00
Michał Pierzchała c84b449ecc fix(android): stamp status-bar chrome during the walk instead of reconstructing it downstream (#1319) (#1359)
* test(settle): pin that an expanded quick-settings shade stays visible to --settle (#1319)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This deletes what was compensating for the loss:

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

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

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

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

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

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

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

Review blockers on 8615771c6:

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

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

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

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

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

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

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

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

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

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

* fix(android): keep chrome provenance internal

* refactor(android): tighten chrome provenance seam

* fix(android): close chrome provenance response leaks

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

* test: cover save-script force retargeting

* fix: address active publication review findings
2026-07-21 20:24:26 +02:00
Michał Pierzchała f087a5938e fix: align Android Maestro gesture dispatch (#1356)
* fix: align Android Maestro gesture dispatch

* fix: preserve Android gesture guarantees
2026-07-21 18:44:08 +02:00
Michał Pierzchała 32ba4b67f4 chore: add FreeRange range-analysis check (#1354)
* chore: add freerange check

* ci: install bun for freerange check

* fix: preserve numeric range contracts

* fix: guard diff overlay geometry

* refactor: isolate diff overlay bounds
2026-07-21 11:49:33 +02:00
Michał Pierzchała 9efe445398 fix: target Maestro scrollUntilVisible container (#1338)
* fix: target Maestro scrollUntilVisible container

* fix: align Maestro scroll viewport selection

* fix: ignore hidden Maestro scroll containers

* fix: derive Android Maestro scroll viewport
2026-07-21 10:54:48 +02:00
Michał Pierzchała c6a8e78768 fix(tvos): skip SpringBoard system-modal probe on tvOS (#1351) (#1353)
tvOS has no SpringBoard (it uses PineBoard/HeadBoard), so probing
com.apple.springboard for blocking system modals raises an XCTest failure
("Application com.apple.springboard is not running") that safely() cannot
trap, terminating the whole runner test. Every snapshot and alert
resolution then failed, and the CLI misreported it as the screen
"overwhelming the accessibility capture".

resolveBlockingSystemModal — the single chokepoint every snapshot/alert
path funnels through — assumed a SpringBoard host always exists. Model that
assumption explicitly with `hasSpringBoardSystemModalHost` and bail to
`.absent` when no host exists, so app-owned alert queries still run. The
fourth probe site (shouldRouteToSpringboardBlockingSystemModal) is already
`#if os(iOS)` and needs no change.

Add tvOS regression tests covering the snapshot and alert-resolution paths.
2026-07-21 07:57:51 +02:00
Michał Pierzchała d9f16de26f docs: retire Maestro compatibility tracker (#1350) 2026-07-20 21:09:11 +02:00
Michał Pierzchała 52e306082f docs: simplify README language (#1352)
* docs: simplify README language

* docs: restore README technical details
2026-07-20 20:25:05 +02:00
Michał Pierzchała b3b9ff59b0 docs: propose active-session script publication (#1347)
* docs: propose active-session replay publication

* docs: address active-session replay review

* docs: align active-session publication on save-script

* docs: qualify destination guard identity

* docs: close active-session publication boundaries
2026-07-20 19:59:52 +02:00
devin-ai-integration[bot] 1f042c3d8d refactor(mcp): extract reference-pin state into tool-ref-pins module (#1344) (#1345)
* refactor(mcp): extract ref-pin state into focused tool-ref-pins module

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

* test(mcp): add success-path ref-pin wiring test

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

* refactor(mcp): narrow tool-ref-pins result types and replace as-cast with type guard

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

* refactor(mcp): remove unnecessary as-casts in tool-ref-pins and command-tools

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

* refactor(mcp): align ref-pin result handling with #1343 typed result projection

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

* refactor(mcp): upstream types for ref-pin module (#1345)

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

* refactor(mcp): introduce honest public targetKind-discriminated interaction response contracts

Replace the public AgentDeviceClient return types for press/click/fill/longpress/find with serialized-payload-shaped response data (targetKind, flat ref/selector/x/y, per-command extras) instead of internal runtime result types. The internal PressCommandResult/FillCommandResult/LongPressCommandResult keep their kind/target shapes for the daemon runtime; the public CommandResultMap now points to the new response contracts.

- Add PressCommandResponseData/FillCommandResponseData/LongPressCommandResponseData/FindCommandResponseData in src/contracts/interaction.ts.
- Update CommandResultMap and command-result tests.
- Add client-facing shape tests asserting the public response data discriminates on targetKind and exposes flat identity fields.

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

* fix(contracts): include cost and iOS Maestro fallback fields in public response contracts

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-20 19:59:35 +02:00
devin-ai-integration[bot] 5a33f8bfc7 feat(maestro): numeric option fields accept ${VAR} lookup interpolation (#1293) (#1342)
* feat(maestro): numeric option fields accept ${VAR} lookup interpolation (#1293)

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

* fix(maestro): validate resolved numeric strings before coercion

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

* fix(maestro): address review feedback on numeric  resolution

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

* fix(maestro-conformance): preserve unresolved ${VAR} numeric tokens in canonical model

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-20 18:06:04 +02:00
Michał Pierzchała a924f0bb3e fix(mcp): restore result and config parity (#1343)
* fix(mcp): restore result and config parity

* refactor(mcp): narrow parsed input types

* refactor(mcp): simplify parity boundaries

* refactor: narrow validated MCP tool inputs

* refactor: preserve command result types in MCP

* refactor: remove impossible MCP result fallbacks
2026-07-20 16:21:06 +02:00
devin-ai-integration[bot] ab804340c8 feat(maestro): support optional on scrollUntilVisible and extendedWaitUntil (#1291) (#1339)
* feat(maestro): support optional on scrollUntilVisible and extendedWaitUntil

Add optional support at command level and element level for
scrollUntilVisible and extendedWaitUntil. The parser now accepts
optional in both positions and propagates it to the command so the
existing optional-command execution boundary downgrades a timed-out
lookup to a warning and continues the flow.

Update the upstream/076_optional_assertion divergence entry so only
assertTrue remains unsupported, and keep the docs/support matrix in sync.

Closes #1291

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

* fix(maestro): reject bare optional selectors, ORed visible/notVisible optionality, and add device differential scenario

- parseMaestroSelectorMapEntries now rejects selectors that contain only
  optional and no real matching criteria, with rejection tests for
  scrollUntilVisible.element, extendedWaitUntil.visible, and .notVisible.
- extendedWaitUntil now rejects simultaneous visible and notVisible conditions
  and derives optionality only from the single condition that will execute.
- Add layer-3 differential flow/scenario optional-warned-scroll-and-wait that
  exercises both command-level and element-level optional on a missing target
  and verifies the flow continues to the final assertion.

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

* refactor(maestro): split parseExtendedWaitUntil to satisfy fallow complexity gate

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-20 14:00:57 +02:00
devin-ai-integration[bot] 18b950407c fix(maestro): consume deferred stability before waitForAnimationToEnd (#1326) (#1337)
- Mark waitForAnimationToEnd as requiring a settled predecessor so it
  consumes the stability requirement parked by a preceding tap, swipe, or
  other mutation. Without this, the engine generation advances past the
  deferred requirement and the next tap's settlePending throws a
  generation mismatch.
- Add a unit test that exercises tapOn -> waitForAnimationToEnd -> tapOn
  through the full daemon replay engine.
- Add a layer-3 differential scenario for the same idiom.

Closes #1326

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-20 12:44:51 +02:00
Michał Pierzchała e7c02a9f4c feat: add advisory device claims (#1329)
* feat: add advisory device claims

* fix: preserve advisory claim ownership

* fix: retain claims after incomplete cleanup

* fix: retain claims across pre-open effects
2026-07-20 11:29:59 +02:00
Michał Pierzchała f4df878a9d docs: improve README onboarding (#1333)
* docs: improve README onboarding

* docs: restore README discovery details

* docs: link README usage proof

* docs: expand README usage proof

* docs: refine README tagline
2026-07-20 10:05:53 +02:00
devin-ai-integration[bot] 0f253f311c Maestro compat: support childOf on assertVisible/assertNotVisible (#1294) (#1334)
* Maestro compat: support childOf on assertVisible/assertNotVisible (#1294)

- Accept childOf at command level in the Maestro IR and parser.
- Thread childOf through the observation condition to the snapshot
  target resolver, reusing the existing ancestor-scoping path.
- Project childOf into the conformance canonical selector so
  upstream/114_child_of_selector matches.
- Remove the stale divergence declaration for 114 and update docs.

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

* test: regression-cover assertVisible/assertNotVisible childOf forwarding

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-20 09:46:05 +02:00
Michał Pierzchała ef118b9d11 ci(test-app): fingerprint-keyed build cache — disk locally, Release artifacts in CI (#1321)
Splits the test app's build caching by context instead of running one remote
cache for both.

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

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

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

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

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

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

The artifact lookup is non-fatal: a query outage leaves the id empty and
falls through to an inline build like a miss does, rather than exiting the
composite under set -e and turning a cache blip into a caller failure.
test/scripts/setup-fixture-app-fallback-smoke.sh drives that step's real shell
against a failing gh and asserts source=build; ci.yml runs it.
2026-07-18 09:36:32 +02:00
Michał Pierzchała d0227998d4 feat: add daemon stop lifecycle (#1323)
* feat: add daemon stop lifecycle

* fix: harden daemon stop cleanup

* fix: fail closed daemon stop cleanup

* fix: bound daemon shutdown lease releases

* fix: await active shutdown lease release

* fix: release provider leases independently on shutdown
2026-07-17 18:21:06 +02:00