366 Commits

Author SHA1 Message Date
Michał Pierzchała d4cf02a889 chore(gates): classify #2278 daemon-platform-runtime edges and ratchet handler session authority (#2354)
* chore(gates): classify #2278 daemon-platform-runtime edges (R74) and ratchet handler session authority (R75)

* chore(gates): discover the R75 shape target from the SessionState declaration

* chore(gates): capture dynamic-import bindings in R74 so symbol drift cannot hide

* chore(gates): reject dynamic-import destructure residue in R74

* chore(gates): R74 rejects open-ended dynamic imports beside named bindings

* chore(gates): renumber daemon-platform-runtime-inventory to R76 (R74 taken on main)
2026-09-08 13:21:29 +02:00
Rohit Bansal d11c8cf9d6 feat: support standalone Maestro clearState command (#2366)
* feat: support standalone Maestro clearState command

Accept '- clearState' / '- clearState: <appId>' in Maestro YAML flows.
Unlike launchApp.clearState (clear-then-open), the standalone form clears
app state without relaunching, projecting to 'settings clear-app-state'
on the daemon. Covers the Rocket.Chat login-with-deeplink helper, which
previously failed with 'Maestro command "clearState" is not supported'.

* test(maestro): cover standalone clearState with authored corpus flow

Replace the UNVERIFIED_COMMANDS exemption with an authored
clear-state flow exercising default and explicit appIds, plus the
regenerated upstream parser fixture proving Maestro compatibility.

Live iOS Simulator evidence (iPhone 16, com.apple.mobilesafari):
- marker files in the data container, then replay '- clearState'
  (default) and '- clearState: <appId>' (explicit) via
  'replay --maestro'; both replay 1/1, wipe the container, and leave
  MobileSafari not running (no reopen).
2026-09-08 12:42:44 +02:00
Michał Pierzchała 1f9d940bff refactor(capture-kit): complete ADR 0019 end state — relocate snapshot and recording zones (#2385)
* refactor(capture-kit): relocate snapshot and recording zones into capture-kit

Move the ADR 0019 end-state capture zones into @agent-device/capture-kit:

- src/snapshot/** -> packages/capture-kit/src/snapshot/** (presentation,
  freshness, scroll-edge-state, ios-snapshot-runtime, android occlusion)
- src/recording/** -> packages/capture-kit/src/recording/**
- src/core/snapshot-{chrome,state,tree-ingestion,node-lookup}.ts ->
  packages/capture-kit/src/
- src/snapshot-quality/ test -> capture-kit presentation tree (directory
  retires with its last file)

Pure renames: import re-pointing and gate updates follow in the next commit.
The snapshot-desktop-surface test parks in src/__tests__/ because it pins
the root eager-import-closure walker.

* refactor(capture-kit): re-point capture and recording consumers to the new subpaths

Rewires every consumer of the relocated snapshot/recording modules to the new @agent-device/capture-kit subpath exports, adds the 23 subpath entries to the capture-kit exports map, fixes the moved recording-scripts test's __dirname-relative paths for the deeper location, and records the completed migration in ADR 0019's end state.

* chore(gates): align layering, mutation, fallow and CI gates with the capture-kit relocation

Moves the executable-policy roots, presentation-owner constant, zone ranks, authority fixture, mutation sharding globs, stryker aliases, fallow baselines and the iOS workflow's android-owned paths-ignore entry onto the new packages/capture-kit paths, and extends the planted-red coverage to the new presentation-owner subpath.

* chore: point capture-domain source-of-truth comments at the relocated capture-kit modules

* test: point shutdown recording mock at capture-kit and cover interactor acquisition presentation

* test(capture-kit): update upstream presentation test imports

* chore(gates): follow relocated snapshot assembly in R74

* test(daemon): freeze prewarm deadline assertion clocks
2026-09-08 12:41:39 +02:00
Michał Pierzchała a6cf1b1fd4 refactor(ios): delete the unused snapshot plan interface (#2392)
`planIosSnapshot`, `IosSnapshotPlan`, the `plan` member of `IosSnapshotEngine`
and `createIosSnapshotEngine` had no production caller: production reaches
presentation through `publishIosSnapshot` / `presentIosSnapshot` directly, and
the barrel re-export was all that kept the factory alive for fallow.

Deleting the plan takes the last reader of most of
`IOS_SNAPSHOT_PRODUCER_CAPABILITIES` with it. The table was typed over all four
producers while only the two provider producers ever consumed its
residue-shaping fields, and it had already drifted: it declared
`simulator-ax-bridge` with `hittabilityEvidence: 'available'` while the bridge
adapter emits `unavailable-fact: hittability` on every capture. Rather than
correct the value, the table is now keyed on `IosProviderAcquisitionProducer`,
so a producer that builds its own facts cannot declare one at all.

Truncation is the one capability the runner and the bridge still need answered,
so it moves to a table of its own over all four producers, read through
`iosSnapshotTruncationEvidence`. Both keep `'available'`, which is what the
adapter and the runner payload actually prove.
2026-09-08 12:41:26 +02:00
Michał Pierzchała 6a03688d80 refactor(ios): prune converged snapshot paths (#2383)
The daemon snapshot assembly no longer presents. `shouldPresentLegacyIosInteractiveSnapshot`
fired whenever an xctest capture arrived without a producer, or with a producer whose capability
table still named `snapshot-state` as its presentation owner — which `simulator-ax-bridge`
still did after routing moved it onto the engine, so a bridge capture with `--interactive-only`
ran the iOS semantic presentation twice (#2188 invariant 2).

Rather than deleting a runtime guard and hoping, `buildSnapshotState` now takes
`SnapshotCaptureProvenance`: a capture either knows nothing about its origin or carries the whole
pair, so the producer-less branch does not compile. Requiring the pair broke only test fixtures,
which is the proof that production never omitted it.

`presentationOwner` had one value left once the bridge was accounted for, so the capability and
its type are gone; the truncation verdict that read it now reads `truncationEvidence`, which is
the fact it was standing in for and matches it producer for producer. Post-wire scope planning
names the channels that still need the pass instead of excluding the ones that do not, which takes
iOS out of it. `compactIosInteractiveSnapshot` was a byte-identical alias of
`presentIosInteractiveSnapshot` with no production caller.

R74 holds it: the assembly and the Simulator bridge producer adapter may not import iOS
presentation, and the assembly may not name the iOS channel or a producer.
2026-09-08 10:36:37 +02:00
Michał Pierzchała a9283fabc7 refactor(move): move replay divergence vocabulary into @agent-device/ad-replay (#2384)
* refactor(ad-replay): move the replay divergence vocabulary into @agent-device/ad-replay

* chore(gates): pin the ad-replay divergence subpath in the layering boundary
2026-09-07 18:25:38 +02:00
Michał Pierzchała 63711929d8 refactor(cli): take the CLI's vocabulary off runtime barrels (#2379)
* refactor(cli): read the cloud provider vocabulary from its own subpath

`provider-policy.ts` asks three questions of `@agent-device/provider-webdriver`:
the known-provider map, the predicate over it, and one type. All three live in
`providers.ts`, a leaf with zero imports. Reaching them through the package
barrel loaded 31 modules of WebDriver runtime — session handling, capture,
XML — into every CLI invocation to answer "is this string a known provider
name".

The package predates the subpath-per-file rule and published only `"."`. It
now publishes `./providers` as well (in the gates commit, with the recorded
surface); this points the sole in-closure consumer at it.

`src/cli.ts` eager closure: 325 -> 295 modules.

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

* chore(gates): publish provider-webdriver/providers and record the surface

The provider vocabulary needs a door of its own so a consumer asking whether
a string names a known cloud provider does not load the WebDriver runtime.
`src/providers.ts` has no imports, so the subpath points straight at it and
costs the package nothing.

R11's recorded export surface for the package grows by exactly that entry;
the assertion is sorted so the list stays order-independent.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 18:23:44 +02:00
Michał Pierzchała 3f022b0730 fix(gates): stop an eager-closure approval from turning main red on merge (#2375)
`no APPROVED_OVER_CEILING row is stale` reads the introduced-entry set, which
is derived from `git merge-base origin/main HEAD`. On a push to main the
merge-base IS the head, so nothing is first-introduced and every approval row
reads as stale whatever its real state. That is exactly the shape of the
approving PR's own merge commit: #2329 added the
`packages/command-registry/src/planned-operations.ts` row to merge, and the
merge that followed it called the row dead. Coverage has been red on main
since (run 34099687663), and every branch cut from main after it inherits the
same failure.

- `staleApprovalRows` makes the verdict a named rule and defers it when the
  merge-base is the head, where no row is readable at all. Enforcement is not
  lost: a row that outlives its PR is still reported on the first branch whose
  merge-base could have read it, and the rule is pinned in both directions.
- The `planned-operations.ts` row goes, which is what the rule asks for now
  that main carries the entry: its closure (74) is governed by the no-growth
  rule from here on, not by the domain-facade ceiling.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 17:05:04 +02:00
Michał Pierzchała d26b0786fb perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner (#2329)
* perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner

Local Simulator opens now decide how much the XCTest runner is needed from the
runtime operations declared by the steps still ahead in the same batch: an
observation-only plan starts no runner, an unknown plan keeps the speculative
prewarm without ever awaiting it, and a plan with an interaction prepares
readiness for that step. open --relaunch no longer waits for runner readiness
on a Simulator and resets the runner target only when a session is already
alive. The Apple find ports report not-proven instead of starting a runner on
a Simulator without a live session, so wait and read-only find observe through
the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged.

The plan travels through the server-private internal request channel, never
the wire; the Apple owner maps declared operations to a runner demand through a
record complete over the runtime operation union.

Refs #2198

* test(fixtures): share one inert audio-probe host across the platform runtime fixtures

The Apple and Android runtime fixtures carried identical audio-probe doubles;
host-kit now owns the one copy and both fixtures import it. Also folds the two
Apple native-find ports onto one admission helper and lifts the Simulator
runner prewarm policy out of the open sequence, keeping both under the
complexity gate.

* fix(ios): answer runner liveness through the runner provider seam

The find ports and the relaunch target reset asked the local session registry
whether a runner was alive, which misreads scripted and request-scoped runner
providers as absent. Liveness is now a provider question: the local provider
consults its session registry, a provider without startup cost counts as live,
and an awaited prewarm proves liveness without asking.

* perf(ios): select plan uses from step input and give young Simulator targets a bounded bridge grace

A snapshot, diff, or find step now selects the runtime uses its structured input
reaches, the way its handler does, so a plain snapshot no longer counts the
custom-actions alternative and an observation-only batch resolves runner demand
none. The descriptor declares the selector next to its alternatives; the daemon
plan derivation honors it and keeps the union for every other command.

Without the runner wait, the first snapshot after an open reached the AX bridge
while the app was still becoming the primary foreground owner or registering
its accessibility server, and the typed fallback then started the runner the
plan had just avoided. A target younger than ten seconds is re-read for a
bounded grace measured from the first such failure: five seconds for a missing
AX server, one second for an ownership miss so a launch-time system dialog
still reaches the fallback quickly. Established targets get no grace.

* fix(ios): a registered runner session counts as live only once it has answered

A session record exists while xcodebuild is still connecting, so an alive
child pid is not a runner that can answer. Treating it as live sent the
relaunch target reset into a starting runner, queued behind its connection
retries, and the failed reset invalidated the very session the prewarm was
building. Liveness now also requires the session's readiness flag, which the
first successful runner response sets.

* refactor(ios): lift the bridge launch grace out of the snapshot route capture

* test(descriptors): pin the snapshot, diff, and find step-use selectors

* test: stub runner operations in the replay test-runner suite and keep runner-session tests within the size ratchet

A Simulator open schedules a best-effort runner prewarm that outlives its
request. The replay test-runner suite opened a Simulator with the real Apple
tools, so the prewarm's deferred import resolved after the file finished and
spawned into whichever file the worker ran next, where the hermetic signal
guard failed an unrelated test.

* fix(plan): count only required operations and read find and snapshot steps the way their handlers do

Runner demand now counts a command's required operations only: a preferred or
conditional operation is a measured fast path the command succeeds without, so
get, wait, and read-only find stay observation-only. The step selectors for
snapshot, diff, and find live next to the registry and read the daemon step
exactly as the handlers do: the daemon flag for custom actions, and find's
positionals through the same parser, where a missing action is a click and an
unparseable step keeps every declared alternative. The handler and the selector
share one action-to-intent map. The batch runner hands each step its remaining
steps in handler shape, and the derived operations reach the platform as a
typed list on the lifecycle execution instead of an untyped plan on every open.

* perf(ios): let open wait for the launched app to become observable, and make runner liveness explicit

The snapshot route no longer infers a launch from process start text and
retries inside its own capture. Open owns launch timing instead: a local
Simulator open asks the AX bridge whether the launched app is observable,
bounded by per-code windows measured from the first typed launch-transition
failure and never extended, so an ownership miss seen after an AX-server miss
shrinks the deadline to the ownership window and a launch-time system dialog
still reaches the typed fallback quickly. Any other device, or a bridge that
cannot answer, keeps the fixed settle. The open response reports what it
learned.

Every runner provider now states whether it can answer without a startup wait;
a bare executor answers directly by construction and scripted providers say so.
The runner prewarm policy and the observation settle move out of the open
sequence into their own module, and the native find admission is named for what
it admits.

* docs(context): keep the runner-demand vocabulary within the guidance budget

The enumeration and the no-public-flag rule live on the contract type that
owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose
words that carried no meaning.

* refactor(contracts): name the runtime operation vocabulary below the operations union

The lifecycle execution carries the operations a plan requires, but typing
that list with the operations union closed a 36-file type cycle: the
operations types depend on the lifecycle types. The vocabulary now lives as a
const list below both, proven equal to the union by a type test, so the plan
is typed end to end, the Apple host table indexes it without casts, and the
daemon narrows descriptor names through a guard instead of a cast.

* fix(apple): reach runner liveness through the memoized operations loader

Every Apple tool port loads the runner operations through the one memoized
loader (#2314): a port that opens its own dynamic import can resolve the
unmocked module while a test's mock factory is still loading and let a real
local runner escape. The liveness port now uses the loader like its siblings;
the facade members consumed only through the loader are declared to fallow,
and the plan resolver reads one step per helper to stay under the complexity
threshold.

* fix(ios): keep bridge-only behavior to iOS Simulators

The launch observation, the runner-free find admission, and the relaunch
policy apply only where the host AX bridge exists: iOS Simulators. A tvOS
Simulator keeps its awaited prewarm and asks for no observation, which the
tvOS provider scenario now pins.

* bench(ios): add a first-interaction cell to the snapshot convergence harness

An open that defers runner readiness moves its cost to the first
runner-dependent command. The cell starts each sample like cold, opens the
fixture untimed, then times the first press that follows (the deep-link
confirmation when the launch URL raises it, otherwise the screen anchor).

* bench(ios): read the deep-link confirmation from a snapshot and by node type

The open response carries no tree and regular snapshots publish the node type,
so the confirmation iOS raises for a launch URL was never seen on this runtime
and every deep-linked cell failed its anchor check.

* refactor(plan): keep the step-use selectors inside the registry

The eager-closure ratchet counts every module the registry loads; the
selectors need nothing the registry does not already import, so they live
beside find's recording-effect reader instead of adding a module to every
entry that loads the registry.

* feat(apple): release a speculative runner when the plan is proven observation-only

#2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a
prewarm started that no command has used yet is speculative: the session records that mark at
creation, the first command that is not a readiness probe clears it, and a Simulator open whose
plan is proven observation-only asks the runner owner to release a speculative session in the
background, so the observation path never waits for a runner to stop either. A runner that has
served a command is the session's working runner and stays under the existing idle-stop policy,
so a mixed workload does not pay a cold runner start at every observation-only open.

The release goes through the runner provider seam: the local provider stops its own speculative
session; a provider that never starts speculative work omits the operation and releases nothing.

* bench(ios): press an unambiguous target on the catalog and Settings screens

The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS
Settings screens names two actionable elements (the native tab and the screen title); the CLI
refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything.
Each such screen now names the element the cell presses.

* fix(ios): keep observation on the bridge while app discovery is pending and no runner is live

#2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback
past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a
relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll
budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no
live runner now stays on the single-flight discovery, one wait slice at a time, until the
discovery's own deadline or the request signal ends it; a runner that is already live still takes
the fallback at once, the cheaper route #2331 chose.

* fix(apple): queue a speculative-runner release behind a start that is still in flight

A `possible` open's prewarm registers its session only when the start completes, so a `none` open
that released in that window found nothing and the runner it meant to release survived as a
retained speculative session. The release now takes the runner session lock: it queues behind the
in-flight start, sees the registered speculative session, and stops it; a start a command asked for
is left alone. Two deferred-start regressions pin both outcomes.
2026-09-07 10:12:58 +02:00
Michał Pierzchała 233a34d138 refactor(daemon): extract the session event journal into a workspace package (#2361)
* refactor(daemon): extract the session event journal into a workspace package

`src/daemon/session-event-*.ts` (6 modules) and `src/core/keyboard-actions.ts` move as
git renames into a new private package `@agent-device/session-journal`. One subpath per
moved module points straight at the moved file; no `index.ts`, no re-export at any old
path. Every consumer switches to the owning specifier.

The journal's request-shaped inputs now name `DaemonRequest`/`DaemonResponse`/
`DaemonResponseData` from `@agent-device/kernel/contracts` instead of the daemon's own
`daemon-request.ts`, which the package may not reach (R11) and which carries `internal`
with its `SessionState` callbacks and admitted `DeviceLease`. The response types were
already re-exports of the kernel ones, so no shape changes; the request type narrows to
the four fields the journal reads.

A type-level test reads every request-shaped parameter off the real signatures and
asserts the reachable type graph declares no `internal` key, holds nothing shaped like a
live session record or a `DeviceLease`, and carries no callback.

The daemon reaches the journal only by workspace specifier now, so the code-signature
walk gets the same pin the descriptor registry got: a walk stopping at the package
boundary would report an unchanged signature after an entry-shape or retention-window
edit, and a client would keep reusing a daemon writing the superseded journal.

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

* chore(gates): rank the session-journal package on the layering spine

R6's drift guard requires every production zone to be a deliberate ranked-or-unranked
decision. `session-journal` is vocabulary the daemon reads a dispatched request through,
so it takes rank 1 beside `command-registry` and `contracts` rather than the unranked
kit treatment: its only ranked edges are to same-rank zones, which is not a back-edge.

No `APPROVED_OVER_CEILING` row and no fallow baseline edit: rename detection carries all
seven moved entries' merge-base closures, so each falls under the no-growth rule, and no
baseline entry was keyed on the old paths.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 07:52:27 +02:00
Michał Pierzchała 51ed6217cc refactor(daemon): relocate the daemon client out of src/daemon (#2360)
* refactor(daemon): extract the repair-tombstone reader below store and client

`findUnrecoveredRepairCommitFailure` reads session artifacts off disk and is
reached from the daemon client, which had to import `session-store.ts` — the
daemon's largest server module — for it. Move the tombstone shape, its file
reader and the unrecovered-commit scan into `session-repair-tombstone.ts`, a
leaf below both, and give the tombstone file name a single owner.

No behavior change; both consumers keep their existing tests.

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

* refactor(daemon): relocate the daemon client out of src/daemon

`src/daemon/client/` is the daemon's client, not the daemon: no daemon file
imports it, and its consumers are the CLI, the Node client, the proxy command
and the injected dispatch type. Move it to `src/daemon-client/` as renames so
`src/daemon` is server code plus the shared kernel the client still needs —
`config.ts`, `daemon-process.ts`, `request-progress-protocol.ts`,
`daemon-request.ts` and the extracted `session-repair-tombstone.ts`.

Zone name and rank are unchanged (`daemon-client`, 5); the zone now falls out
of the folder instead of a `src/daemon/client/` prefix. Tests move unchanged.

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

* refactor(daemon): move the session artifact path helpers out of session-store

`src/cli.ts` and `src/remote/remote-request-diagnostics.ts` reach into
`session-store.ts` for one pure path function, `resolveRemoteRequestDiagnosticsPath`,
which made every CLI process eagerly evaluate the daemon's session store and
its whole subtree — the script writer, the event log, the action recorder and
the replay transaction vocabulary.

The four artifact path helpers name files; they hold no store state. Move them
to `src/daemon/session-artifact-paths.ts`, a leaf over `session-paths.ts`, and
point all ten consumers at it. `src/cli.ts`'s eager closure drops from 379
modules to 365 and no longer contains `session-store.ts`; the store itself is
464 -> 341 lines. AGENTS.md's declaration-site pointer follows.

No behavior change: the helpers are unmodified.

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

* chore(gates): re-key the daemon-client gate paths onto src/daemon-client

Path-keyed enforcement follows the relocated files: the fallow health baseline
entries, the oxlint per-file override, the wire-compat surface/ledger/mutation
paths, and the layering zone derivation (the `src/daemon/client/` prefix is
dead now that the folder itself names the zone).

R10's external daemon request/session-state importer list gains the five client
modules. The edges are unchanged by this PR — the client has always built
`DaemonRequest` and read `DaemonResponse`; it sat inside `src/daemon/` and so
fell under the prefix skip. Naming the files keeps the dependency enumerated
and shrink-only, so a new `src/daemon-client/` module reaching `session-state`
still fails. Its size assertion now reads the recorded list instead of a
literal.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 07:49:19 +02:00
Michał Pierzchała bd08e6e0f2 refactor(contracts): move single-owner modules out of @agent-device/contracts (#2357)
* refactor(daemon): move root-only contracts vocabulary into its owning zone

Six @agent-device/contracts modules had no consumer outside the root zones,
so the shared vocabulary package carried types only the daemon and root
composition ever read. Each one moves to the zone that owns it and every
consumer switches to the owning module; no re-export stays behind at the old
contracts path.

- perf-runtime-plan, snapshot-timeout-evidence, platform-resource-cleanup ->
  src/daemon
- daemon-owner-cleanup -> src/
- interaction-error -> src/core

wait-runtime-plan stays in contracts: @agent-device/command-registry consumes
it, so it is not root-only after the registry package landed.

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

* refactor(platform): move single-consumer contracts modules into their platform package

Four modules in @agent-device/contracts had exactly one consuming package, so
the shared vocabulary carried Android- and Apple-specific shapes no other zone
could use. Each moves into the package that owns it, with every consumer
switched to the owning module and no re-export left at the old contracts path.

- android-helper-artifacts -> platform-android/src/helper-artifacts.ts
- android-touch-plan -> platform-android/src/touch-plan-lowering.ts, which
  also retires the package-local touch-plan.ts re-export barrel that existed
  only to give the contracts module a local name
- snapshot-presentation -> platform-android/src/snapshot-presentation-node.ts
  (renamed to keep the package's existing Android-specific
  snapshot-presentation.ts distinct)
- apple-multitouch-support -> platform-apple/src/multitouch-support.ts

APPLE_OS_DISPLAY_NAMES folds into gesture-admission.ts, its one remaining
contracts caller, so both gesture refusals still share one copy of the wording
without a new contracts subpath for a table its own doc calls non-public.

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

* refactor(core): move the replay divergence implementation into src/core

replay-divergence.ts mixed the wire vocabulary every zone reads with the
sanitizing, bounding and reporting implementation only root zones call. The
ten value consumers are all root (daemon replay, the session replay
coordinator, the daemon client lifecycle, the replay-test reporter, the
command error projection, and the MCP tool error), so the implementation moves
to src/core/replay-divergence.ts and carries its test unchanged.

The types stay in contracts and keep the @agent-device/contracts/divergence
subpath, which packages/ad-replay and packages/selectors type-import.
ReplayVarScrubEntry follows the implementation: it is the sanitizer's own
parameter shape, not part of the divergence wire report.

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

* chore(gates): shrink the contracts export surface by the moved subpaths

The nine relocated modules no longer live in @agent-device/contracts, so its
exports map drops their subpaths (118 -> 109) and
scripts/layering/contracts-exports.snapshot.json is regenerated from the
manifest, which is what R11 package-boundaries diffs the live surface against.

The two resolution assertions naming the retired snapshot-presentation and
snapshot-timeout-evidence subpaths go with them; interaction, snapshot and
react-native-overlay still cover both the direct-module and facade shapes the
assertions were there to prove.

The property tests that needed fast-check left with snapshot-presentation and
replay-divergence, so the dependency moves too: contracts drops it and
platform-android declares it, as fallow's unused-devDependency check reports.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00
Michał Pierzchała 2ec4e91b11 refactor(core): move the command descriptor registry into its own workspace package (#2348)
* refactor(core): move the command descriptor registry into its own package

`src/core/command-descriptor/`, `src/command-catalog.ts`, `src/core/wait-positionals.ts`
and `src/core/parse-timeout.ts` move as git renames into a new private package
`@agent-device/command-registry` (deps: contracts, selectors). One subpath per module
points straight at the moved file; no `index.ts`, no re-export at the old path. Every
consumer switches to the owning specifier.

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

* test(host-kit): pin the command-registry package inside the daemon code graph

The daemon reaches the registry and its catalog only by workspace specifier. A walk
that stopped at the package boundary would report an unchanged signature after a
descriptor edit, and the client would keep reusing a daemon running the superseded
policy. The manifest is asserted beside the sources because its `exports` map is what
chose them. The cache doc comment quoting the old ~800-module graph is corrected.

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

* chore(gates): point the descriptor-registry gates at the package path

R66's `COMMAND_DESCRIPTOR_MODULE`, R16's record-runtime join subject and the Fallow
`AssertTrue` totality-guard key follow the registry to its package. The two descriptor
hubs leave `HUB_ENTRY_FILES` because the package manifest now publishes them, so the
eager-closure gate discovers them as facades and one entry gets one rule; this also
flips `denyPlatformImplementations` from false (hub) to true (package entry) for both,
which is intentional and stricter. `command-registry` joins the ranked spine at rank 1.

No `APPROVED_OVER_CEILING` row: rename detection carries every moved entry's merge-base
baseline, so all twelve fall under the no-growth rule rather than a ceiling.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00
Michał Pierzchała dcd8b65d4c refactor(daemon): split src/daemon/types.ts into request types and session state (#2346)
* refactor(daemon): split daemon/types.ts into request and session-state modules

`src/daemon/types.ts` served two audiences from one file: the dispatch request
shape and the daemon's live session record. It also sat in the only daemon type
cycle — it imported `RefFrame` from `ref-frame.ts`, which imported `SessionState`
back — so neither file could be read in isolation.

Three modules replace it, each importing only downward:

- `daemon-request-wire.ts` declares `DaemonWireRequest`: a dispatched request
  with no `internal` key and no property path to `SessionState` or `DeviceLease`,
  so a consumer can read a request's command, flags and public metadata without
  depending on the session record.
- `daemon-request.ts` adds the daemon-only half (`DaemonRequestInternal`, which
  stays unexported) plus the response vocabulary.
- `session-state.ts` owns `SessionState` and the shapes only it holds.

The cycle is cut by `ref-frame-slot.ts`, declared below both `ref-frame.ts` and
`session-state.ts`: it owns the frame VALUE (the class stays unexported, so the
type remains nominal and unconstructible from outside), while `ref-frame.ts`
keeps every lifetime transition and every `session.refFrame` write.

No behavior change: every importer moves to the module owning the symbol it
uses, with no re-export shim at the old path. `client-normalizers.ts` takes
`SessionRuntimeHints` from `@agent-device/kernel/contracts`, which declares it.

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

* test(daemon): assert the wire request shape cannot reach session state

A type-level walk over `DaemonWireRequest` fails `tsc` if the shape regains an
`internal` key or grows a property path back to `SessionState` or `DeviceLease`.
Positive controls over `DaemonRequest` prove the walk finds both when they are
there, so a walk that never matches anything cannot pass by accident.

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

* test(daemon): keep the three over-budget test files at their base length

Splitting `daemon/types.ts` turns one combined import into two in every file
that used both halves. Three of those test files are already over the 1,000-line
tripwire, where the size ratchet allows no growth, so each sheds one line that
was carrying nothing:

- `snapshot-handler.test.ts` and `find.test.ts` each drop a `toHaveLength`
  assertion an adjacent `toEqual` on an explicit array literal already makes.
- `session-replay-repair-transaction.test.ts` names the filtered close actions
  instead of wrapping the expression across three lines inside `expect`.

No assertion is weakened and no test content is removed. Splitting these files
along the modules they mirror is the standing remedy, but none of those modules
split here, so it stays out of this change and is tracked in #2353.

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

* chore(gates): point the daemon modularity and wire-compat gates at the split modules

R7 now locates the `SessionState` declaration by the declaration itself rather
than by a recorded path: `sessionStateWritePressure` measures the merge-base
tree too, and that tree still declares it in `daemon/types.ts` — a path constant
would measure it as zero pressure and bank the headroom.

R10's external-importer ratchet covers all three modules that replaced
`daemon/types.ts`, so moving a symbol between them cannot reopen the boundary to
a new outside zone. The recorded membership is unchanged: `client-normalizers.ts`
and `remote/daemon-artifacts.ts` both import `daemon-request.ts` only.

The daemon RPC closure gate waives `DaemonRequest`, `DaemonResponse` and
`DaemonArtifact` by path, so those three keys follow the declarations to
`daemon-request.ts`. `DaemonRequest`'s rationale now says what it is — the
server-side narrowing of the kernel declaration that fixes the wire shape —
rather than calling it a re-export alias.

The `live-state-shape` and session-resource declaration sites move with
`SessionState`; the depgraph lookalike fixture takes a new plausible path now
that `daemon/session-state.ts` is the real root.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:36:54 +02:00
Michał Pierzchała b4ebd778cc refactor(daemon): move four pure leaves to their kits (#2347)
* refactor(selectors): own the parameterized recorded fill leaf

`parameterized-recorded-fill.ts` has no value dependency on the daemon: it
reads a `TargetAnnotationV1` type from contracts and calls
`selectorContainsValue`, so its whole value graph already sits inside
`@agent-device/selectors`. Move it there behind its own subpath and let the
two daemon consumers reach it by specifier.

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

* refactor(host-kit): own the daemon code signature leaves

`code-signature.ts` fingerprints a checkout from `node:crypto`/`fs`/`path`
and `findProjectRoot`; `code-signature-cache.ts` adds a stat-validated cache
over it through `publishFileSync`. Neither reaches the daemon, and both
questions — what does this source tree hash to, and can that hash be replayed
from stat alone — are host mechanics.

Move both into host-kit behind their own subpaths, carrying
`code-signature-cache.test.ts` unchanged apart from its specifiers, and let
the launch spec and server lifecycle reach them by specifier.

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

* refactor(capture-kit): own the screenshot overlay cluster

`screenshot-overlay.ts` decides which snapshot nodes earn a ref and where the
ref lands on a screenshot; `screenshot-overlay-draw.ts` paints them. Both read
kernel snapshot vocabulary, contracts snapshot predicates, and capture-kit's
own PNG and rect-projection mechanics — nothing from the daemon. The two
`src/snapshot/screenshot-overlay/` helpers had no other importer, and
`react-native-overlay.ts` sits on kernel plus its contracts vocabulary alone.

Move the cluster into capture-kit as flat siblings of the PNG and projection
modules it already used, exposing `./screenshot-overlay` and
`./react-native-overlay`; the draw, rects, and android halves stay package
internals with no subpath of their own. The moved tests carry over unchanged
apart from their specifiers, over a package-local snapshot-state fixture.

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

* refactor(capture-kit): own the post-gesture stability loop

`post-gesture-stability.ts` polls a caller-supplied snapshot function until a
surface settles. It is generic over its snapshot and signature types and reads
only host-kit diagnostics and `sleep`, so the loop is capture mechanics with no
daemon knowledge; the daemon keeps the pending record, the comparator, and the
verdict wiring it hands in.

The verdict test stays in `src/daemon` because it composes the loop with the
daemon's own `interaction-outcome-policy.ts`; only its specifier changes.

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

* chore(gates): pin the four leaf subpaths in the R11 export lists

R11 pins every workspace package's exact subpath set, so the four moves need
their new specifiers named: `@agent-device/selectors/parameterized-recorded-fill`,
`@agent-device/host-kit/code-signature{,-cache}`, and
`@agent-device/capture-kit/{screenshot-overlay,react-native-overlay,post-gesture-stability}`.

The selectors comment counted its subpaths in prose; it now counts four and
says what the fourth is. No eager-closure row is needed: every new entry is a
rename the merge-base reader follows, and each closure is unchanged
(56/5/19/32/4/8), so all six fall under no-growth rather than the new-entry
ceiling.

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

* refactor(capture-kit): drop the needless duplication suppression

The `fallow-ignore-next-line code-duplication` on the package-local
snapshot-state fixture suppressed nothing: `fallow dupes` reports three clone
groups on this tree and the fixture is in none of them, with or without the
comment. A suppression that matches no finding is dead weight at best and a
stale-suppression failure at worst.

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

* fix(host-kit): follow workspace subpaths when fingerprinting daemon source

`walkDaemonCodeGraph` followed relative specifiers only, so a source checkout's
signature covered whatever the daemon still imported by relative path. That was
already lossy and the leaf moves made it wrong: the walker itself, the overlay,
the recorded-fill and the stability loop all left the graph, so editing them no
longer changed the signature a client compares a running daemon against, and
the cache's format guard lost the "the walk invalidates every document" property
its comment rests on. Measured from `src/daemon.ts`: 619 modules on main with
the walker stamped, 611 after the moves with it gone.

Resolve a scoped specifier through the owning workspace package's `exports` map
and walk into the file it names. The manifest is stamped, not merely probed, so
an `exports` retarget invalidates without either endpoint changing; an
uninstalled package is recorded as an absent path. Installed dependencies are
still not followed — they change on install, not on edit — and the test is
structural rather than a name pattern. The graph is now 1459 modules and ~112ms
cold, which is what the stat-validated cache exists to absorb.

Regression: five of the six new walker tests fail against the previous walker,
including one that stamps the real daemon graph and asserts the walker is in it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 11:47:17 +02:00
Michał Pierzchała 7bea29d61e fix: repair main after the managed-allocation move landed under stale PRs (#2328)
`main` has been red since #2308. That PR was authored before #2316 moved
managed-device allocation into `@agent-device/managed-allocation`, so the daemon
files it added still import pre-move sibling paths that no longer exist:

    src/daemon/managed-device-allocation/lease-admission.ts(19,8): error TS2307:
      Cannot find module './record-validation.ts'
    src/daemon/managed-device-allocation/__tests__/lease-admission.fixtures.ts(10,61):
      error TS2307: Cannot find module './fixtures.ts'

Typecheck, Repo Guards, and the two managed provider-integration suites all fail
on it, which makes every open PR red.

- `lease-admission.ts` now reaches the record validators through the package's
  `./record` surface, which re-exports them. The `TS2322` at line 70 was a
  consequence of the unresolved import, not a separate defect: with the module
  resolved, `isVerbatimId` narrows `identityIncarnationId` again.
- The daemon-side grant fixtures come back under `src/daemon`, stated in
  contract terms only. Both trees keeping their own test data is the shape #2316
  already chose for `managed-device-allocator.fixtures.ts`.
- The root now consumes `@agent-device/managed-allocation`, so its
  `ignoreDependencies` entry — whose comment said "no root consumer yet" — goes.

Separately, the eager-closure ratchet was failing on a stale approval row: the
merge-base now carries `packages/capture-kit/src/durable-capture/index.ts`, so
nothing can read its `APPROVED_OVER_CEILING` row and the table's own staleness
rule fails it. Removed, exactly as the rule prescribes.
2026-09-06 09:51:56 +02:00
Michał Pierzchała 6e22e266d7 refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon (#2322)
* refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon

Move the pure wire vocabulary (base path, header names, URL/auth/tenant
builders, /health payload) from src/daemon into @agent-device/contracts as
the daemon-http subpath, so src/remote and src/cli stop importing daemon
server internals. buildDaemonHealthPayload takes the version its caller
advertises (R18 keeps host mechanics out of contracts); both callers pass
readVersion(). Wire-compat surface, mutation, and ledger references follow
the package path.

* chore(gates): pin the moved daemon HTTP wire surface and teach the released-baseline check file moves

Exports map + snapshot gain the daemon-http subpath. The wire ledger
re-keys the eight moved declarations (buildDaemonHealthPayload moves with
its new caller-supplied version parameter, acked additive). The
released-baseline comparison now classifies a baseline declaration that
re-appears unchanged at exactly one new path as a move instead of a
removal: a file move is not wire surface a released peer stopped sending.
A move that changes shape is a change acked at the destination path, and a
name still owned by the baseline stays a removal.
2026-09-06 08:20:39 +02:00
Michał Pierzchała ebdaa7617e feat: delegate reviewed managed automation (#2312)
* feat: delegate reviewed automation through managed lease authority

* fix: preserve lazy simulator readiness through scoped authority

* fix: admit managed operations at their dispatch boundary

* test: move managed automation scenarios to integration lane

* chore(gates): declare the private managed readiness scope export
2026-09-06 08:10:59 +02:00
Michał Pierzchała bcb6c55b7f refactor(capture-kit): move durable-capture resource mechanics out of the daemon (#2320)
* refactor(daemon): give durable capture a session-store port and a cleanup report

The durable-capture mechanics reached two daemon-owned authorities directly:
the concrete `SessionStore` class plus `SessionState`, and the admission
ledger, which `recoverFailedAdoption` called to block or clear a replacement
start. Both are daemon policy, so neither can travel with the mechanics.

Replace them with a two-member `DurableCaptureSessionStore<S>` port and a
session type parameter, and let the mechanics report what they observed —
`DurableCaptureCleanupOutcome` — while `createDurableCaptureResource` keeps
the clear/block decision and the reason text. Recovery takes the session
directory resolver from its caller instead of importing `safeSessionName`.

Splitting `DurableCaptureRecordDefinition` out of the definition says which
half needs a session at all: recovery, finish-recovered, and start preflight
terminalize a persisted record with no session in hand.

* refactor(capture-kit): move durable-capture resource mechanics out of the daemon

The daemon held two halves of one mechanism. capture-kit already owned the
durable-resource envelope, JSON, and descriptor codec; the fence, transition,
adoption, and recovery mechanics that operate on that envelope still sat in
`src/daemon` as eight files.

Move them behind the store port and cleanup report the previous commit
introduced, exposed through one new `@agent-device/capture-kit/durable-capture`
subpath — not the `.` index, which is the eager closure every platform runtime
imports. Admission, start preflight, runtime binding, the kind stamps, and the
composition root that wires the mechanics to the admission ledger stay daemon
policy.

The moved tests exercise the mechanics through a resource kind and session type
of their own, so what they prove is that the mechanics need neither the daemon's
closed kind set nor `SessionState`. The composition root keeps the admission
mapping the adoption test used to assert, now in
`durable-capture-resource.test.ts` where the ledger lives.

* refactor(capture-kit): drop the now-dead durable-envelope decoder re-export

The daemon's store and adoption modules were the `.` index's only production
consumers of `decodeDurableResourceEnvelope`; both now sit beside the encoder
inside capture-kit and import it directly.

* chore(gates): approve the durable-capture subpath over the domain-facade ceiling

* refactor(daemon): merge the duplicated durable-capture subpath imports

* refactor(capture-kit): keep the durable-capture subpath to its consumed surface

`tsc -b` cannot name the fixture spy's inferred type across the package
boundary, and five re-exported vocabulary types had no consumer.

* style(capture-kit): keep package specifiers ahead of relative imports

* test(daemon): make the failed-adoption clear mapping effective

The confirmed-cleanup test started with an unblocked ledger, so deleting
`clearUndurableCleanup` from the relocated composition mapping still left
`assertStartAllowed` green. Seed a block first, so the assertion is that the
mapping lifted it.
2026-09-06 07:57:15 +02:00
Michał Pierzchała e0f8c55f6e refactor(move): move managed device allocation into its own workspace package (#2316) (#2321)
* refactor(move): move managed device allocation into its own workspace package (#2316)

* chore(gates): unrank the managed-allocation zone, declare its durable-json seam, and ignore its unconsumed root dependency (#2316)
2026-09-06 07:32:25 +02:00
Michał Pierzchała 4b7c561d1e refactor(commands): author each executable command once in its facet (#2313)
* refactor(commands): let the facet derive its executable definition

* refactor(commands): author interaction commands once

* refactor(commands): author system commands once

* refactor(commands): author capture commands once

* refactor(commands): author management commands once

* refactor(commands): author observability commands once

* refactor(commands): author recording commands once

* refactor(commands): author replay commands once

* refactor(commands): author debugging commands once

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

* refactor(commands): author metro commands once

* refactor(commands): author batch commands once

* refactor(commands): author perf commands once

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

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

The integration-progress scanner mapped client calls to command names by
finding defineExecutableCommand blocks. Those are gone; a facet's run
callback now holds the call, and its name may be a module constant.
2026-09-05 23:12:41 +02:00
Michał Pierzchała 0c8227e9b7 refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* style: apply oxfmt

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

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

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

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

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

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

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

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

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

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

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

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

* style(apple-runner): oxfmt the cache-metadata module and its tests
2026-09-05 22:15:14 +02:00
Michał Pierzchała 7a2d48d160 perf: bundle runtime dependencies and report full install size (#2310)
* perf: bundle runtime dependencies and report full install size

* refactor: remove unused size report breakdowns
2026-09-05 21:38:20 +02:00
Michał Pierzchała 006f2d9f60 chore(gates): layering baselines ratchet against merge-base (#2299)
* refactor(layering): ratchet R6, R9 and R10 against the merge-base tree

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

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

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

TYPE_INVERSION_BASELINE, LARGEST_TYPE_CYCLE_ZONE_CEILINGS, TYPE_CYCLE_BASELINE
and DAEMON_MODULARITY_BASELINE.sessionState were the hand-edited references
these three ratchets compared against. The merge-base measurement replaces
them, so there is no number left to leave above the tree and no entry to raise.
externalDaemonTypesImporters stays: it names files, not a count.
2026-09-05 20:48:46 +02:00
Michał Pierzchała a04b587993 chore(layering): derive the contracts export inventory from package.json (#2297)
* chore(gates): derive the contracts export inventory from package.json

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

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

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

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

Planted red: widened packages/contracts/package.json#exports with a
throwaway "./planted-red-widen" subpath, then separately deleted the
"./wait" subpath; `node --test --experimental-strip-types
scripts/layering/package-boundaries.test.ts` failed both times on the
new deepEqual with the regenerate-and-review message. Reverted before
committing.
2026-09-05 20:47:40 +02:00
Michał Pierzchała 35362fe517 refactor(cli): let help resolve command aliases itself and retire R12 (#2293)
* refactor(cli): let cli-help resolve the --help alias itself

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* style: apply oxfmt

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(mcp): split the navigation schema tests out of command-tools.test.ts
2026-09-05 19:30:21 +02:00
Michał Pierzchała fa06c8c6e9 fix(daemon): fence managed readiness behind runtime admission (#2280) 2026-09-05 07:53:07 +02:00
Michał Pierzchała 5bb3ea3b2a feat(ios): productionize Simulator AX snapshot bridge (#2277)
* feat(ios): productionize Simulator AX snapshot bridge

* fix: address Simulator AX bridge review comments

* docs: refresh Simulator AX evidence

* fix: address new Simulator AX bridge review comments

* docs: record public snapshot source timings

* fix: preserve size report helper on base checkout

* fix: allow base packages without snapshot bridge

* fix: close simulator snapshot source ownership gaps

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

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

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

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

* style: format coverage-changed run.ts

* docs(agents): restore the session.ts over-budget rule
2026-09-04 11:58:01 +02:00
Michał Pierzchała 172ee149cf feat(screenshot): add --crop-on to crop captures to a selector frame (#2276)
* feat(screenshot): add crop-on geometry core and cropTarget selector rows

* feat(screenshot): declare crop-on flag, script round-trip, and snapshot runtime plan

* feat(screenshot): run the crop leaf after the platform write and before scale

* feat(screenshot): expose --crop-on in the CLI and surface crop warnings

* chore(gates): declare crop-on capture-kit subpaths and scope the crop scenario exemption

* refactor(screenshot): split crop target/policy module and trim redundant coverage

Address review comments at 570da2c417:
- Split the 328-line screenshot-crop.ts leaf: the target acceptance matrix,
  classifier, and pre-device argument policy move to screenshot-crop-target.ts,
  so both implementation modules meet the 300-line target.
- Reuse kernel isPositiveFiniteRect/rectArea in the rect-projection module
  instead of redefining them locally.
- Drop the crop-on CLI forwarding case (redundant with screenshot-options
  flag-mapping coverage + the generic dispatcher) and the transport-based
  warnings case, replacing the latter with a focused screenshot-result unit
  test. This also returns the two legacy aggregate test files to their
  merge-base length for the test-file size ratchet.

* refactor(screenshot): extract macOS crop-target decision to keep classifier under the complexity budget

classifyAppleCropTarget inlined the macOS surface decision, pushing its
cyclomatic complexity to the fallow threshold. Move it back out to a
small helper so the target classifier stays within budget.

* refactor(screenshot): dedupe the meaningful-signal predicate and polish png-crop

- Hoist isMeaningfulSignal into @agent-device/contracts/snapshot (next to
  normalizeType/isMeaningfulLabel) so the ref overlay and the crop
  rect-projection share one copy instead of each carrying an identical
  private predicate. Behavior is unchanged.
- png-crop: isCropBox was a no-op 'box is Rect' predicate (input already
  Rect) — make it a plain boolean, and tighten the doc to the contract.

* refactor(screenshot): drop the dead crop outcome flag and cover the projection seams

- ScreenshotCropOutcome.cropped was a constant true that no caller read;
  the crop either returns (success) or throws, so the outcome reduces to
  the partialIntersection observation.
- resolveScreenshotRectSpace and resolveSnapshotBounds were the only
  projection exports without coverage: pin the accepted-backend map, the
  unaccepted-backend typed refusal, and the viewport-root / union / empty
  bounds branches.
2026-09-04 11:18:57 +02:00
Michał Pierzchała 658f822c40 fix: encode the mcp subcommand in server.json package arguments (#2275)
* fix: encode the mcp subcommand in server.json package arguments

A registry-format launcher (e.g. one consuming /.well-known/mcp.json or the MCP registry entry) starts the server from the package descriptor only; without the positional "mcp" argument it runs the bare CLI instead of the stdio MCP server (bin.ts only starts the MCP server for the mcp subcommand).

Enforce the argument in scripts/sync-mcp-metadata.mjs so sync and the CI/prepack checks (check:mcp-metadata) keep server.json correct, and regenerate server.json.

* test: own the registry launch-argument invariant; add changelog entry

- scripts/__tests__/mcp-metadata.test.ts asserts the checked-in server.json's agent-device npm package entry declares the exact fixed positional mcp argument (and stays stdio-only), so a missing or wrong argument fails the unit lane in both directions. Wired into the unit-core project include list.
- Changelog: user-visible release fix under Unreleased.
2026-09-04 10:53:04 +02:00
Michał Pierzchała 33084c7748 perf(ios): decide Simulator AX bridge viability (GO, Node-direct guest reader) (#2237)
* test(ios): add guest simulator AX bridge evidence

* test(ios): make alert cleanup selector unique

* test(ios): admit recovered alert cleanup surface

* test(ios): narrow AX spike to guest evidence path

* docs(ios): record guest AX bridge decision

* chore(ios): remove unused spike import

* docs(ios): correct simulator bridge verdict

* test(ios): drive the guest Simulator AX bridge directly from Node

Replace the idb companion + Python reader in the #2192 spike with a Node client
for idb v1.5.2's in-Simulator SimulatorFrameworkBridge: one private guest per
session spawned through simctl, 4-byte length-prefixed JSON over a UNIX socket,
single-fetch traversal with automation mode asserted per request, nested trees
flattened to parent-linked raw nodes with XCTest type names, and typed
crash/timeout/cancel/stale-generation failures.

The targeted harness now observes app readiness with a throwaway probe instead
of admitting on pid presence, relaunches the app per bootstrap sample, records
host load per sample, and runs recovery probes through the adapter. Hard tiers
follow the corrected #2192 contract (warm 300/500 ms, relaunch 500 ms); the
former 75/150 ms and 250 ms values are reported as stretch findings. Preboot
preference edits are optional and unused by the guest path.

The prototype's targeted artifact is preserved under a -python-prototype name;
its bootstrap and recovery samples measured the packaging, not the mechanism.

* test(ios): narrow Simulator bridge decision evidence

* docs: publish Simulator bridge evidence out of tree

* fix: tighten iOS bridge evidence gates

* docs: publish corrected bridge evidence

* docs: point to post-rebase bridge evidence
2026-09-04 07:38:36 +02:00
Michał Pierzchała 941ca0e7e0 ci: enforce the image-size parser mitigation through a test-app gate (#2269) 2026-09-03 21:36:11 +02:00
Michał Pierzchała e882cf9723 feat(runtime): add managed-local ownership and the exact-only managed runtime (#2258)
* docs: trim the CONTEXT.md glossary within the guidance byte budget

CONTEXT.md sat at 11,992 of its 12,000-byte guidance budget, so no new domain term could be added
without first paying for it.

- Condense eighteen definitions that had grown past one line (platform leaf, command surface,
  runtime use, runner command traits, interactor, coordinate-first resolved element activation,
  parent-owned touch point, guarantee cell, delegation-on-error, ref frame, snapshot producer,
  snapshot policy facet, capture hint, regular presented-depth frontier, clip fold,
  AX-unavailable target invalidation, Maestro program, Maestro observation generation). The
  definitions keep their meaning; only the elaboration is gone.
- Move the five test-harness terms of 'Providers and tests' (provider-backed integration
  scenario, provider transcript, scenario transcript, in-process provider scenario harness, HTTP
  contract test) to docs/agents/domain.md, which AGENTS.md already routes to for domain
  vocabulary. None of them names a concept a command or a wire shape carries, and none appears in
  a test name.

CONTEXT.md is 10,517 bytes after this pass.

* feat(runtime): add the managed-local owner kind, device-claim rule, and managed binding fence

ADR 0021 foundations, unit 1. Nothing registers a managed local owner yet, so every arm below is
reached from tests only; the point of the unit is that the arms exist and fail closed.

- `RuntimeOwnerRef` gains `{ kind: 'managed-local'; instance }` with `managedLocalRuntimeOwner`:
  one owner per allocator instance, family-agnostic because the device carries its family. Every
  owner-kind discrimination becomes an exhaustive switch, so a fourth kind is a type error at each
  site: the owner key, the unavailable-facts provider mode, the durable envelope decode, and the
  gateway's provider-mode acceptance and exact-owner selection.
- `deviceClaimRuleForOwner` ('ordinary' | 'allocator-held' | 'none') in the new leaf
  src/daemon/device-claim-rule.ts replaces the boolean `isLocalDeviceClaimTarget`. Both claim
  gates switch on it, and the admission gate now evaluates it under every device-claim policy: the
  `transient-exclusive` condition moved inside the ordinary arm, so a managed owner is verified
  where an ordinary owner would never have touched the store.
- `requireAllocatorHeldDeviceClaim` (src/daemon/device-claim-allocator.ts) is the one read-only
  verifier both gates consult. It never acquires, never locks and never clears; in this unit it
  can only answer `binding-invalid`, `missing`, or `conflict`, because no allocator-held claim
  kind exists until unit 2. `allocatorHeldAdmissionError` answers each outcome with its own
  refusal through an exhaustive switch, so an outcome the verifier learns to produce is a
  compile error until it is answered.
- A missing allocator-held claim refuses with COMMAND_FAILED / `allocator-claim-missing`,
  `retriable: false`. It is deliberately not a `DeviceClaimConflictReason`: replay retries every
  conflict reason as infrastructure, and a managed identity no allocator activated is permanent.
- `managedBindingFence` / `decodeManagedBindingFence` encode `[requesterId, identityIncarnationId]`
  as the fence token and the request generation as its generation, so two requesters on one
  identity incarnation never share a fence. The ids are fenced verbatim, and the decoder accepts a
  token only if it re-encodes to itself.
- Claim admission now receives the binding intent the gateway bound, so an exact-owner fence
  reaches the gate unchanged. Session open still binds ordinarily and passes an ordinary intent:
  a managed local owner is therefore refused there structurally, and the Host open route replaces
  that intent when it lands.
- CONTEXT.md: managed local owner, device-claim rule, managed binding fence, request generation,
  identity incarnation.

* fix(daemon): decide allocator-held admission totally instead of by an optional error

`allocatorHeldAdmissionError` returned `AppError | undefined`, so its switch without a default
was never exhaustiveness-checked: TS2366 fires only when the return type excludes `undefined`,
`noImplicitReturns` is off, and oxlint has no exhaustiveness rule. A verifier outcome nobody
answered would therefore fall out as `undefined`, which both gates read as an admission — claim
admission throws nothing and session open proceeds to open the session on a device it never
verified.

Replace it with `decideAllocatorHeldAdmission`, returning
`{ admitted: true } | { admitted: false; error }`. The return type excludes `undefined`, so
dropping an arm is now a compile error at the switch, and a gate asks whether the outcome was
admitted rather than whether an error happened to come back. `buildAllocatorHeldRefusal` and the
admission gate are projections of that one decision.

* docs: restore the meaning five CONTEXT.md definitions lost in the trim

The condensing pass shortened these five past the point where they still said what they meant:

- Capture hint said 'presented depth' where the term is 'regular presented depth', which is what
  Regular presented-depth frontier is measured against; the short form read as a different axis.
- Clip fold lost both that the interpreter runs inside presentation for every backend and that a
  platform difference may not enter as a backend exception. Those are the whole rule.
- Snapshot policy facet lost the process boundary that makes it host-side at all: runner-side
  Swift presentation stays separate.
- Runner command traits lost 'independently of the public command surface', which is what
  distinguishes them from the command surface.
- Delegation-on-error said 'settles', and Settled observation makes 'settle' a term of its own.

CONTEXT.md is 11,674 of its 12,000-byte budget.

* docs(daemon): correct the claim-gate and managed-owner comments

- The claim-gate docstring claimed there is no other way to obtain device operations. That is
  true of command handlers, but two daemon-owned recovery paths bind outside the seam:
  application-lifecycle-recovery.ts (ordinary intent, daemon shutdown) and
  durable-capture-runtime-recovery.ts (exact-owner intent read back from a durable envelope,
  which this unit makes able to carry a managed local owner). Name them instead of claiming
  coverage the seam does not have.
- The open path's comment described a session executing under an allocator-held claim, a state
  this route cannot produce. Say what the `{ kind: 'ordinary' }` literal actually is: the truth
  of a route that binds ordinarily, which the Host open route replaces with the request's exact
  intent when it lands.
- Name U3 as the unit that fills the exact-owner selection arm, rather than the whole ADR.

* fix(runtime): accept transport-composed facts for a managed owner

providerModeMatchesOwner's managed-local arm accepted mode === 'local' only, but
selectExactOwner's managed-local arm loads the device's local family owner through the same
loadLocal a local-family owner uses, so it inherits that owner's provider modes verbatim. A
managed binding over a transport-composed local device (e.g. a remote ADB or web-provider
transport) would fail bindingContractFailure's facts check and be rejected as an owner/facts
mismatch. Accept the same local-family modes the local-family arm already does; still
unreachable until U3 registers the exact-only owner, which is where the binding regression
test that pins this lives.

* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port (#2259)

* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port

ADR 0021 foundations, unit 3. Unit 1 added the `managed-local` owner kind and left the gateway's
exact-owner arm for it failing closed; this unit gives that arm a registry and the owner it selects.
Nothing in production registers a managed owner yet, so both are reached from tests only.

- `createComposedPlatformRuntimeGateway` gains a `managedOwners` list that only the `managed-local`
  arm of `selectExactOwner` reads. `selectOrdinaryProvider`, `inspectFacts` and the ordinary `bind`
  arm never see it, and `providerModules` pairs one provider-runtime owner with one
  `ProviderDeviceRuntime`, so ordinary selection cannot reach a managed owner by construction
  rather than by a check. A duplicate instance is refused at composition.

- The wrapper (src/platform-runtime-managed-owner.ts, root zone, no platform imports) binds only
  under an exact-owner intent naming itself, loads the device's own family owner through the
  gateway's loader, delegates with an ordinary intent — a family owner refuses a foreign exact
  owner — and republishes the binding under the managed owner. It does not read the fence: what a
  managed binding fence proves is the device-claim gate's business. `ownsDevice` returns false.

- Twenty cells are withheld as `owner-capability-missing`, enumerated by mechanics rather than by
  catalog group: the four device-lifecycle cells, the four application cells that boot or shut the
  device down (`prepareApplicationOpen`, `prepareAppleRunner`, `closeApplication`,
  `finalizeApplicationClose`), and the twelve durable-capture cells, which a managed binding could
  never reattach because the family runtime stamps envelopes with its own local owner. The
  operations are then filtered by those facts, so an operation cannot outlive its own fact.

- `@agent-device/contracts/managed-device-allocation` is agent-device's own allocator port: lease
  request, lookup, supersession, cancellation, renewal, release, activation confirmation, identity
  status, removal acknowledgement, and the typed environment projection. Types only, named to match
  the allocator's published contract so the two sides cannot drift, with no dependency on any
  allocator package. Its only implementation is a scripted fake under `*.fixtures.ts`.

- Budgets: the new contracts entry surface is a one-module closure; the `src/platform-runtime.ts`
  hub moves 47 -> 48 for the wrapper, whose own value imports were already in that closure.

* fix(runtime): withhold the deployment cells from a managed binding and trim the allocator port

Review findings on the managed local owner.

- `deployApp` and `deployMaterializedApp` join the lifecycle group. Both family deployment runtimes
  ensure device readiness before installing, and `deployAppUse` requires `deployApp` alone — so
  `install` on a managed binding would have booted the allocator's device with nothing to refuse
  it. Twenty withheld cells become twenty-two, and the refused-uses test covers `deployAppUse`.

- The wrapper's doc comment no longer implies that withholding cells is a complete lifecycle
  exclusion: several retained Apple cells (screenshot capture, settings, clipboard, application
  launch) boot the simulator lazily inside the family runtime, where cell selection cannot reach.
  That is the same class as the pre-binding readiness path, and closing it is a family-runtime
  change.

- `readLeaseEnvironment` leaves the allocator port. It was beyond the vocabulary the contract
  fixes, and it made the scripted fake carry a real parser whose only test passed with every
  production line reverted. `ManagedLeaseEnvironment`, `ManagedLeaseEnvironmentKey` and
  `LeaseEnvironmentError` stay as types; the reader that produces them lands with the unit that
  first turns a grant into a device.

- CONTEXT.md drops an operation enumeration that was already incomplete.

* fix(runtime): withhold the lazily-booting Apple system and screenshot cells

Screenshot capture, settings, clipboard and application launch were retained on a managed
binding even though their Apple family-runtime implementations can boot the simulator lazily
below cell-selection granularity (screenshot's shutdown-failure retry boot; settings, clipboard
and application launch each resolve a local interactor the same way). That preserves rather than
blocks the exact bypass ADR-0021 section 3's hard boundary names: managed lifecycle/readiness
belongs to the allocator, and no handler path may fall back to direct lifecycle tooling.

Withhold captureScreenshot, setSetting, readClipboard, writeClipboard and openApplication
alongside the existing withheld groups. The wrapper's doc comment now names the pre-binding
readiness gap explicitly as the same class of follow-up, rather than folding it into a retained-
cells caveat that no longer applies. MANAGED_RETAINED_OPERATION moves to tapPoint, the cell the
fixture-based regression tests now use to prove something survives the wrapper.

* chore: retrigger CI (stale synchronize event after rebase)

* fix(runtime): lazy-load the managed owner wrapper to satisfy the eager-closure no-growth gate

Main's eager-closure budget gate (the merge-base ratchet) replaced the hand-tracked
HUB_BUDGETS map with an automatic no-growth-vs-merge-base check: src/platform-runtime.ts
is a hub with no growth allowed at all, not a number bumped by hand with a justifying
comment. The static import of createManagedLocalRuntimeOwner in platform-runtime-gateway.ts
added one module to that hub's closure (47 -> 48), which now fails
scripts/__tests__/eager-closure-budgets.test.ts outright rather than needing a manual bump.

Move the value import into loadManaged's dynamic `await import`, matching how the rest of
this file's owner loaders defer their leaf modules. Only the managed-local arm reaches this
path, so an ordinary bind never pays for it, same as before -- the wrapper module itself was
simply the wrong side of the eager/lazy line.
2026-09-03 19:41:53 +02:00
Michał Pierzchała e2ce98556b chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings (#2257)
* refactor(closure): walk a source tree through a reader seam

The eager-import-closure walker read the working tree directly through fs, so
every consumer could only ask about the checkout in front of it. Closure
computation now takes a SourceTreeReader; the working tree stays the default,
and a committed git tree answers the same four questions for any tree-ish
without checking it out -- one `git ls-tree` for the tracked set and one
long-lived `git cat-file --batch` for the sources the walker can reach.

Per-tree memoization of package directories and direct edges, plus a
content-keyed parse cache, keep a second tree paying only for what differs.

* chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings

The 202 façade and 6 hub numeric pins are gone. The six platform façades stay
exact at one module, every other existing entry may evaluate no more than the
same file evaluated at the merge-base with origin/main (renames followed), and
an entry that did not exist there fits a per-category ceiling derived from its
path, or carries an APPROVED_OVER_CEILING row naming issue, reason and owner.

Shrinking now needs no gate edit, and a stale approval fails. The standing
denial -- a façade closure never reaches a concrete platform implementation
before discovery or binding selects an owner -- is unchanged.

* chore(gates): scope stale approvals to introduced entries and keep readers in sync

Address review findings on the eager-closure merge-base ratchet.

- docs/agents/testing.md: drop the new bullet. The file was 386 bytes over the
  10,000-byte focused-doc budget, and the gate module's header already owns the
  invariant, so the prose was duplication the ownership rule forbids.
- The closure walker's relative resolver no longer tries a .tsx suffix. The repo
  defines a production source as .ts (tracked-sources.ts pathspecs and
  isProductionSourceFile), so the committed-tree reader never loads .tsx content;
  resolving one produced an edge that reader could not read, crashing the ratchet
  instead of failing it.
- The APPROVED_OVER_CEILING staleness check now looks only at entries still
  first-introduced. Once the merge-base carries an entry, the no-growth rule
  governs it and nothing reads its row again, so the row is stale for the same
  reason a shrunk entry's row is.
2026-09-03 18:01:12 +02:00
Michał Pierzchała e3c44ea4c3 test(vitest): retire the subprocess-stub kill-criterion experiment (#2255)
* test(vitest): record the subprocess-stub kill-criterion outcome

#1823's kill criterion was met (~64 consecutive genuine Coverage-job
completions since dbc4f2f955 with zero timeout-shaped failures), so
the subprocess-stub project is gone for good rather than mid-experiment.
Rewrite the vitest.config.ts comments to state that resolved outcome
instead of framing it as an ongoing revert-on-first-failure trial.

* test: remove retired subprocess project traces

* test: model the active fuzz worker project
2026-09-03 17:34:17 +02:00
Michał Pierzchała fbf914b907 chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch (#2251)
* chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch

The three hash-named raw results under scripts/ios-snapshot-benchmark/evidence/
are measurement output, not fixtures. They now live on the orphan branch
evidence/ios-snapshot (commit 2d4baf461a); the in-tree README records each
file's sha256 and the fetch recipe, and fetched copies are gitignored there.

scripts/ios-snapshot-benchmark/evidence.ts validates an evidence directory
against the raw-result schema and the published hashes
(pnpm bench:ios-snapshot:evidence -- [--evidence-dir <dir>]). Its tests run
on a two-cell excerpt of the warm/relaunch result and skip the corpus check,
naming the fetch command, when the directory holds no evidence.

* fix(bench): require the full published evidence corpus and pin an immutable ref

runEvidenceReport accepted any nonempty schema-valid directory, so the
default directory could pass with only one of the three published
files present. The default directory now names any missing published
filename(s); an explicit --evidence-dir stays permissive by design.

The evidence/ios-snapshot branch tip is mutable and was the only
fetchable source. Pin every fetch command and the README to the
annotated tag evidence/ios-snapshot/71fb2483f and the full commit SHA
2d4baf461a instead of FETCH_HEAD.

* fix(bench): stop exporting evidence.ts internals with no consumer

EVIDENCE_TAG, EVIDENCE_COMMIT, and missingPublishedEvidence are used only
inside evidence.ts. runEvidenceReport is the CLI entry point invoked
through the file's own `node evidence.ts` guard, not through an import,
matching the unexported runDeepButtonRule pattern in the sibling
deep-button.ts script.
2026-09-03 17:17:40 +02:00
Michał Pierzchała a4f625c774 feat: add strict wait absent polling (#2236) (#2264)
* feat: add strict wait absent polling

* fix: keep wait absent coverage gates green

* fix: preserve wait absent restart diagnostics
2026-09-03 14:01:15 +02:00
Michał Pierzchała a5a7f6dfa1 docs(layering): kill criteria on every rule module (#2244)
Adds a four-line Catches/Evidence/Cost/Kill-criterion header to every
layering rule module for R2, R4-R7, R9-R14, R16, R18, R19, R65-R73,
and the rule-id uniqueness gate, so each structural check states what
it catches, why no other gate sees it, its LOC cost, and the concrete
condition under which it gets deleted. No behavior change.
2026-09-03 11:13:15 +02:00
Michał Pierzchała 2371ba9bff feat: add strict native absence assertion (#2245)
* feat: add strict native absence assertion

* fix: address absence assertion review feedback
2026-09-03 08:02:09 +02:00
Michał Pierzchała 6a24dc1b2d chore(depgraph): stop re-deriving the layering inversion baseline (#2241)
* chore(depgraph): stop re-deriving the layering inversion baseline

The report's typeInversionsByPair and the gate's checkTypeInversions run the
same loop over the same resolveImportEdges output, so asserting that the
report reproduces TYPE_INVERSION_BASELINE over the real tree checked one
code path against itself. Replace the tree-wide cross-check with a synthetic
test of the report's own counting rule (raw edges, once per file pair).

* chore(gates): retitle the depgraph gate as the report's model tests

The Layering Guard step no longer claims to agree the report with the gate;
it runs the depgraph model and blast-radius tests, which the gate manifest
requires a registered check to own.

* docs: clarify inversion ratchet ownership
2026-09-02 21:31:54 +02:00
Michał Pierzchała 5eebba5fcd refactor(layering): one retired-paths rule for src/utils and src/replay (#2240)
R14 (src-utils-retirement) and R71 (replay-ownership) were the same
path-prefix denylist instantiated in two modules. Fold both into
scripts/layering/retired-paths-policy.ts, driven by a table keyed by rule
id; ids, messages, inputs (tracked src/utils paths vs production sources)
and check.ts wiring are unchanged.
2026-09-02 20:51:02 +02:00
Michał Pierzchała fbf6097700 chore(gates): drop the test-file size pin map, keep the merge-base ratchet (#2238)
The exact-length pin map duplicated what the merge-base already records and
made every shrink a two-file edit. The gate now has one rule: a test file over
the 1,000-line tripwire may be no longer than at the merge-base with
origin/main, and no new test file may cross the tripwire.
2026-09-02 20:49:47 +02:00
Michał Pierzchała 2c7fb93cfc fix(android): apply settings airplane through the connectivity service (#2234)
* fix(android): apply settings airplane through the connectivity service

settings airplane wrote airplane_mode_on and then broadcast
android.intent.action.AIRPLANE_MODE, which Android refuses for non-system
callers. The write landed, the broadcast failed, and the device reported
airplane mode with the radios still up.

The connectivity service now owns the change: it is read to prove the build
supports airplane mode before anything is written, driven with
cmd connectivity airplane-mode enable|disable, and read again so the response
reports the mode connectivity holds rather than the one requested. Builds
without that command are refused unmutated with UNSUPPORTED_OPERATION.

Closes #2223

* test(android): pin the mechanics eager closure at 178 modules

Splitting the airplane owner out of settings.ts adds one module to the
mechanics facet, which is implementation-eager by design. The row moves to the
measured number in the PR that grows it.

* fix(android): report only capability absence as unsupported airplane mode

An unrecognized nonzero probe — a permission denial, a connectivity-service
error — was answered with "requires Android 11; use a newer device". Only the
prose adb prints when a build ships no shell implementation for the command
now selects UNSUPPORTED_OPERATION; every other failed read stays
COMMAND_FAILED with its classified hint, and the write is unreachable from
both.

The predicate that reads that prose already existed for the clipboard service
and is now named for the question it answers, so airplane mode reuses it
instead of adding a second message sniff.
2026-09-02 16:54:19 +02:00
Michał Pierzchała 7ee1a5ded7 refactor(ios): carry provider acquisitions through one presentation owner (#2233)
* refactor(ios): centralize provider snapshot presentation

* fix(ios): close provider snapshot ownership gaps

* fix(ios): enforce provider snapshot ownership boundary

* fix(capture-kit): preserve snapshot engine lazy closure
2026-09-02 14:06:33 +02:00
Michał Pierzchała 1f0eedff89 refactor(daemon): move shared snapshot execution out of handlers (#2232)
* refactor(daemon): move shared snapshot execution out of handlers

* fix: remove retired snapshot health baseline
2026-09-02 12:46:44 +02:00
Michał Pierzchała 6c8c0508d9 refactor(ios): converge Limrun snapshots through engine (#2222)
* refactor(ios): converge Limrun snapshots through engine

* fix(limrun): defer snapshot engine loading

* fix(limrun): harden snapshot viewport evidence

* fix(limrun): preserve snapshot engine evidence

* fix(limrun): preserve unknown snapshot truncation

* refactor(ios): reuse private presentation evidence seam

* test(ios): remove stale presentation assertion binding

* test(ios): extract snapshot truncation regressions

* test: ratchet snapshot suite size pins

* test(snapshot): cover provider presentation ownership

* test(snapshot): type Limrun composition fixture

* test(snapshot): exercise public Limrun runtime composition
2026-09-02 10:15:40 +02:00