87 Commits

Author SHA1 Message Date
Michał Pierzchała b2b084d2e1 docs: fix phantom specifiers, the duplicate ADR 0019, and the Node floor (#2533)
AGENTS.md routed request cancellation/progress and diagnostics to
`@agent-device/capture-kit` subpaths that no package exports; both live in
`@agent-device/host-kit/request` and `@agent-device/host-kit/diagnostics`. It also
named `@agent-device/contracts` as an importable seam although that package
publishes no root export, and claimed `src/daemon/handlers/session.ts` was over
budget after that extraction already landed at 242 lines.

Two ADRs carried number 0019. The hop trace has its own claims to make, so it now
numbers 0023, joins the index, and keeps the links from ADR 0019 and ADR 0022.

The Node floor split was undocumented: `engines.node` stays at 22.12 because CI
installs the published tarball on that floor, while contributors need 22.13 for the
pinned pnpm. CONTRIBUTING now says so, and installation.md names the 22.12 floor and
the web backend's Node 24 requirement.

Extend the agent-guidance contract to resolve every `@agent-device/*` specifier
AGENTS.md names against the owning package's `exports`, root included, so neither a
phantom subpath nor a phantom package root can route an agent to a module that does
not exist.
2026-09-13 10:15:57 +02:00
Michał Pierzchała df0a0f7fd2 perf(package): strip comments from the Apple runner source the npm package ships (#2467)
* perf(package): strip comments from the Apple runner source the npm package ships

The packager copies apple/runner/** into dist/ as Swift source, removing only
its AGENT_DEVICE_RUNNER_UNIT_TESTS blocks, so doc comments and design notes were
downloaded on every install: 71.9 kB of 441.2 kB of packaged runner Swift.

Add a lexical scanner for the removal. A regex cannot do this: `//` and `/*`
open a comment only in code position, raw literals move their own delimiter and
escape with the `#` count, interpolation segments hold code and further
literals, and Swift block comments nest. A construct the scanner cannot account
for throws at packaging time instead of shipping Swift that does not compile.

* fix(package): keep Swift regex literals out of the comment scanner

`#/foo//bar/#` is a valid extended regex literal with no comment in it, but the
scanner only knew the `#"` raw-string family, so it read the literal's `//` as a
line comment and shipped `let pattern = #/foo` — Swift that does not compile.
Add `#/…/#` and `##/…/##` as a literal context: matching `#` counts, the
single- and multi-line forms, Swift's own-line rule for a multi-line closing
delimiter, and the `\/` escape that keeps one from closing early.

Bare `/…/` literals stay unresolvable, because the same `/` opens a comment,
divides, and starts a regex literal, and only the parse separates them. Where
one could begin — an expression position whose `/` is not followed by a space,
a tab or `)` — packaging throws by file and line instead of rewriting bytes it
cannot prove are code. Divisions (`width/2`, `Double(3)/Double(4)`), the
recording scripts' shebang and `(/)` keep flowing through.

* fix(package): keep the packaged runner source on the checkout's line numbers

`dist/apple/runner/**` is the Swift a user's `xcodebuild` and the runner name a
file and line in (it lands in runner.log), so those numbers are only worth
reading if they point at the same line of `apple/runner/**`. Both rewriting
passes now empty the lines they remove instead of deleting them: comment removal
(889 lines, 889 B) and the pre-existing unit-test `#if` block strip, which was
moving everything below a block by up to 883 lines (3,737 lines, 3,737 B).

`dist/apple/runner/` 555,907 B -> 488,635 B (-67,272 B, -12.1%); its Swift alone
441,196 B -> 373,924 B (-15.2%). Parity costs 4,626 B of the 71,898 B the
previous head saved.

Nothing in the repo compiles the packaged source, so a mis-lex that failed to
throw would ship Swift that does not build and no gate would see it. Add
`pnpm check:packaged-runner-swift`: it packages into a throwaway root and asserts
line-count parity plus the line of every declaration each packaged file still
carries, then runs `swiftc -parse` over all 44 files. The parse half reports
itself skipped where no Swift toolchain exists, so the gate is declared on the
macOS lane, where both halves run.
2026-09-11 12:00:02 +02:00
Michał Pierzchała 220bab08ba chore(gates): name the added modules and import paths when an eager closure grows (#2471)
* chore(gates): name the added modules and their import paths when an eager closure grows

The no-growth diagnostic in scripts/__tests__/eager-closure-budgets.ts only named the
FIRST newly evaluated module and always advised a dynamic import. On #2423 that sent
five reviewers toward the wrong fix when the growth was a small new module that
belonged in a module every affected entry already evaluated -- the dynamic-import
advice was never coherent for a brand-new module with no old edge to defer.

- describeClosureGrowth now lists every added module (bounded to 10), each with the
  shortest static import route from the entry to it.
- describeSharedGrowthHomes runs once after every entry is evaluated: when two or more
  entries grew by the same added module, it names the modules they already evaluate at
  the merge-base under that module's own package -- candidate homes, not a verdict.
- classifyGrowth's closing advice now states the two common causes (a new static edge,
  or something that used to load lazily) and the two remedies (give the symbol a home
  in a module already in the closure, or make the new edge lazy) instead of prescribing
  one fix.

The verdict logic (when an entry is flagged as having grown) is unchanged.

* chore(gates): split the shared-growth-homes diagnostic into small helpers

* chore(gates): aggregate only net growth and keep shared homes per added module

The cross-entry shared-homes note took every entry with a newly evaluated
module, which is not the condition the per-entry rule applies: a closure that
swaps one module for another, or shrinks while adding one, has added modules and
no growth. `classifyGrowth` passes it, so the aggregate must too -- entries now
carry their head closure size and the grouping keeps only the ones whose closure
actually grew.

Candidate homes are no longer unioned across added modules. Each added module
shared by two or more grown entries gets its own block naming those entries with
how much each grew and the merge-base modules exactly those entries evaluate, so
the label no longer claims a home is common to every failing entry when two
independent groups are in play.
2026-09-10 20:10:01 +02:00
Michał Pierzchała 0dfd65f6a2 perf(ios): speed up deep snapshots and keep first taps reliable (#2414)
* perf(ios): recover deep snapshots and isolate optional tap probes

* chore(gates): enforce snapshot assets and optional probe lifecycle

* fix(ios): preserve capture bounds and local probe recovery

* chore(gates): validate base package assets with its own policy

* chore(gates): verify recovery failures respect launch observation policy

* fix(ios): fail closed on unknown snapshot frontier completeness
2026-09-09 18:24:15 +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 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 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 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 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 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 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 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 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 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 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 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 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 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
Michał Pierzchała 947582a3cc refactor(daemon): move interaction and find routes behind facade (#2178) (#2228) 2026-09-02 07:59:06 +02:00
Michał Pierzchała b15121ffc8 test(ios): establish snapshot convergence baselines and permanent evidence (#2204)
* test(ios): add snapshot convergence evidence harness

* fix(ios): satisfy benchmark CI guards

* fix(ios): constrain benchmark proxy routes

* fix(ios-benchmark): enforce cell admission evidence

* fix(ios-benchmark): protect benchmark state ownership

* fix(ios-benchmark): use proxy port flag

* fix(ios-benchmark): let proxy choose an ephemeral port

* test(ios-benchmark): keep CLI process seam local

* fix(ios-benchmark): parse proxy startup envelope

* fix(ios-benchmark): bind proxy lease to simulator

* fix(ios-benchmark): keep fresh proxy CLI sessions isolated

* fix(ios-benchmark): preserve async timeout evidence

* docs(ios-benchmark): retain exact-head evidence

* test(ios): reveal offscreen alert fixture controls

* test(ios): reset alert between relaunch samples

* test(ios): admit native alert snapshots

* docs(ios): publish snapshot convergence corpus

* chore(ios): format benchmark evidence

* fix(ios-benchmark): admit proxy fixture anchors

* docs(ios): republish exact-head benchmark corpus

* fix(size): make publish asset evidence hermetic

* style(size): format package evidence test

* test(size): update publish preparation contracts

* fix: retire stale utils layering zone

* test: pin shared publish asset owner

* test: verify preserved size reporter closure

* fix: move mutation ownership to snapshot module

* test(ios): add snapshot convergence evidence harness

* fix(ios): satisfy benchmark CI guards

* fix(ios): constrain benchmark proxy routes

* fix(ios-benchmark): enforce cell admission evidence

* fix(ios-benchmark): protect benchmark state ownership

* fix(ios-benchmark): use proxy port flag

* fix(ios-benchmark): let proxy choose an ephemeral port

* test(ios-benchmark): keep CLI process seam local

* fix(ios-benchmark): parse proxy startup envelope

* fix(ios-benchmark): bind proxy lease to simulator

* fix(ios-benchmark): keep fresh proxy CLI sessions isolated

* fix(ios-benchmark): preserve async timeout evidence

* docs(ios-benchmark): retain exact-head evidence

* test(ios): reveal offscreen alert fixture controls

* test(ios): reset alert between relaunch samples

* test(ios): admit native alert snapshots

* docs(ios): publish snapshot convergence corpus

* chore(ios): format benchmark evidence

* fix(ios-benchmark): admit proxy fixture anchors

* docs(ios): republish exact-head benchmark corpus

* fix(size): make publish asset evidence hermetic

* test(size): update publish preparation contracts

* fix: keep git-state gates out of mutation sandboxes
2026-09-01 21:39:05 +02:00
Michał Pierzchała 110c08c947 refactor(transport): move shared host mechanics (#2221) 2026-09-01 19:45:28 +02:00
Michał Pierzchała 1826b2e68b refactor(ios): integrate runner with snapshot engine (#2214)
* refactor(ios): integrate runner with snapshot engine

* fix(ios): preserve macOS runner snapshots

* refactor(ios): keep runner presentation device-aware

* fix(ios): validate runner scroll presentation

* fix(ios): close presenter package boundaries

* fix(ios): preserve snapshot source lineage

* test(ios): colocate snapshot engine coverage

* fix(ios): settle post-merge audit checks

* test(ios): fix manifest parity lint

* refactor(ios): simplify runner source walk

* test(ios): cover shared package source fixture

* fix(ios): close post-merge audit gaps

* perf(ios): avoid bundling acquired snapshot path
2026-09-01 18:36:09 +02:00
Michał Pierzchała 7646a73b1b perf: avoid redundant physical iOS runner health check (#2215)
* perf: avoid redundant physical iOS runner health check

* test: isolate iOS runner prewarm coverage
2026-09-01 15:30:00 +02:00
Michał Pierzchała 81a9cb2b3c feat(daemon): establish interaction application facade (#2205)
* feat: establish interaction application facade (#2177)

* fix(daemon): narrow interaction runtime request seam
2026-09-01 10:46:54 +02:00
Michał Pierzchała b042045522 refactor(output): split presentation owners (#2202)
* refactor(output): split presentation owners

* fix(output): keep candidate rendering in surface owners
2026-09-01 07:50:25 +02:00
Michał Pierzchała 010f09bf0d refactor(daemon): move open lifecycle behind session facade (#2201) 2026-08-31 21:18:58 +02:00
Michał Pierzchała d330a679e2 fix(apple): switch to manual code signing when a provisioning profile is set (#2172)
* fix(apple): switch to manual code signing when a provisioning profile is set

CODE_SIGN_STYLE was hardcoded to Automatic even when
AGENT_DEVICE_IOS_PROVISIONING_PROFILE was configured, so xcodebuild rejected
the resulting PROVISIONING_PROFILE_SPECIFIER + CODE_SIGN_STYLE=Automatic
combination with "conflicting provisioning settings" on physical-device runs.

Fixes #2153

* fix: satisfy formatting and the test-file size ratchet

- oxfmt: wrap the long array literal in the new manual-signing test.
- runner-client.test.ts was already pinned at the 1000-line tripwire
  (1577 lines); adding a test grew it past the pin, which the ratchet
  test rejects by design ("extract instead of adding to a file over
  the tripwire"). Extract the pure runner-cache-metadata.ts build-
  settings tests (signing, bundle, performance, sandbox args) into a
  new runner-cache-metadata.test.ts, shrinking runner-client.test.ts
  to 1441 lines and lowering its pin to match.
2026-08-31 16:28:27 +02:00
Michał Pierzchała f513b1d4ae refactor: extract daemon replay behind one application facade (#2166)
* refactor: extract daemon replay behind application facade

* fix: address replay facade review findings

* test: close replay ownership import scan gap

* fix: tighten replay capability boundaries
2026-08-31 15:43:33 +02:00
Michał Pierzchała ed26b31c94 refactor: contract Apple platform surface (#2125)
* refactor: contract Apple platform surface

* refactor: use Apple plugin seam in tests

* test: ratchet snapshot handler size
2026-08-29 13:10:48 +02:00
Szymon Dziedzic d9677301f4 feat: add human takeover controls (#2078)
* feat: add human takeover controls

* fix: harden human takeover controls

* fix: align host XCTest selection count

* fix: address takeover readiness feedback

* fix: handle macos runner permission prompt in smoke tests

* fix: detect background macos permission dialog

* fix: dismiss inaccessible macos privacy sheet visually

* fix: map macos privacy prompt without accessibility

* refactor: own human-control holds in lease registry

* fix: cancel pending human takeover on disconnect
2026-08-29 13:09:54 +02:00
Michał Pierzchała 9abcd7fe03 refactor: move Apple platform family into package (#2118)
* refactor: move Apple platform family into package

* fix: preserve Apple facade sync contracts

* fix: complete Apple W4 rebase review fixes
2026-08-28 15:25:44 +02:00
Michał Pierzchała c7f42ccedc refactor: move Android family behind package exports (#2117)
* refactor: move Android family behind package exports

* fix: address Android W5 review feedback

* fix: update relocated routing fixture assertion
2026-08-28 13:02:28 +02:00
Michał Pierzchała af6f12e391 chore: adopt shared oxlint config (#2115)
* chore: adopt shared oxlint config

* fix: preserve project lint boundaries

* fix: remove redundant oxlint config
2026-08-28 11:42:58 +02:00
Michał Pierzchała 437465f37b ci(1874): declare the diagnose lane and read its iterations honestly (#2059)
The loop that #1874 is investigated with could not tell the truth about itself.
It classified every non-`passed` iteration as a stall, which after #2035 gave the
looped test an XCTSkipIf meant an environment flip would report a 100% stall
rate; it captured cadence only for failures, though an absorbed episode now
passes; and it read its logs with shell pipelines whose exit status means "did
this match", so an iteration that legitimately matched nothing killed the job
before it could be summarized.

scripts/diagnose-1874-iteration.ts reads one iteration: xcodebuild's own verdict,
the `type-all` duration, and the cadence worth keeping. A nonzero exit outranks a
green measured test — in `pair` mode the neighbour or the runner can fail while
the measured test passes — and a run that produced no verdict is named as ours
rather than counted as a stall. The workflow gains the #1781 lane declaration it
never had. Its kill criterion names #2080, which the loop can now serve rather
than merely claim to: the looped test is a dispatch input, so the fill route that
#2080 traces loops the same way. One test pins the contract the script cannot
check about itself — that the workflow hands it the status xcodebuild returned
rather than a literal.

Closes #1874.

Both filed symptoms are resolved. `smoke:form-input` was root-caused and fixed in
#2035: the fixture's placeholder was identical to the value every suite filled,
so `fill` could never be verified on the penalized route — deterministic, not a
flake, and only visible under load because that route is gated on a penalized
XCTest channel. The targeted XCTest is mitigated by the progress-aware commit
budget, with 200 consecutive green loop iterations across two dispatches.

The issue's remaining question — why the input pipeline throttles — is answered
by the second dispatch, and the premise was wrong: it does not. Posting 17
characters took 484 ms and the commit was observed on the first poll, inside an
iteration whose `type-all` measured 14334 ms. The ~12.6 s went to accessibility
round-trips before any character was posted, which is #1105's path, not the
input pipeline's.
2026-08-28 08:33:46 +02:00
Michał Pierzchała ddb415a2c7 refactor: sink package-closed src modules into existing packages (#2106)
* refactor: sink package-closed src modules into existing packages

Move closed modules into contracts, kernel, capture-kit, and ad-script,
and declare DaemonCommandDescriptor in core so R6/R9 can pin the remaining
provider-webdriver type cycle.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: keep contracts and capture-kit off generic sinks

Move interaction-outcome, snapshot warning rendering, and inventory ALS
behind focused owners, and plant R18/R70 domain-shape gates so they
cannot return as package export-map growth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop moved implementation comments from owner modules

Names, types, and tests already carry those invariants; the relocated
files should not keep review-history or control-flow narration.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop the empty snapshot-quality layering zone

W1 moved the verdict into capture-kit and this PR moved warning rendering
into snapshot-presentation, so the ranked zone no longer has production files.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 07:56:18 +02:00
Michał Pierzchała e832325e87 refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1)

The shared src/utils closure that blocked the platform-family moves lands
on declared owners: generic host mechanics form a new private
@agent-device/host-kit package between kernel and capture-kit, and
capture-kit keeps capture, snapshot, and recording behavior, depending on
host-kit for the mechanics it needs. tar-stream and yauzl move with the
archive code.

Every seam's exported subpaths are pinned in package-boundaries.test.ts,
the layering model ranks the new zone, R13's allow-list names it, and each
seam carries an exact eager-closure row. ADR-0019's substrate amendment
describes the layout.

Tests that mocked two of the moved modules separately became duplicate
same-seam vi.mock factories, where the second silently replaced the first;
those are merged, and the mocks that production code reaches past are
pinned at their injection points instead.

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

* refactor(host-kit): one narrow capability port per export

The four technical barrels (exec/fs/values/request) grouped by category
rather than by capability, so a consumer needing one mechanic evaluated
unrelated ones. Each export is now a single capability over the host
machine: command, process, diagnostics, retry, archive, file, request,
version. A port re-exports only what a consumer of that capability uses,
and every port carries its own eager-closure row.

Most of the old values barrel was never host mechanics. Pure record
readers, config-source values, result text, memoization, async scoping,
coordinate validation, and device-scope parsing touch no process, file, or
environment, so they join kernel's other primitives instead.

Closures fall accordingly: capture-kit's png-worker-client from 20 to 10,
png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from
386 to 380.

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

* chore: drop the migration inventories and trim the touched comments

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

* docs: trim the touched host-kit and mutation-lane comments

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

* docs: keep tool directives only in the touched files

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

* docs: keep tool directives only across the touched tree

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

* fix: point the Swift parity comment at the real TS twin and test

The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts,
which does not exist: the module went to capture-kit while isTapPointInsideViewport itself
went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at
the pre-move path. Both now resolve.

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

* fix: repoint comment citations at the homes this refactor moved them to

The W1 move left ~20 comment citations pointing at src/utils/*.ts and
src/request/*.ts paths that no longer exist. Each now names the capability
port that owns the symbol, which survives further file moves:

  exec -> host-kit/command          host-process, owner-identity -> host-kit/process
  diagnostics -> host-kit/diagnostics   atomic-file, process-lock -> host-kit/file
  retry -> host-kit/retry           request progress/cancel -> host-kit/request
  version -> host-kit/version       ttl-memo, source-value, parsing, device-isolation,
                                    keyed-lock, success-text -> kernel subpaths

Comment-only; no closure, budget, or behavior change. ADR citations are left
as written, being dated records of the decision rather than live references.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00
Michał Pierzchała c794c11d7e refactor: close the daemon platform boundary (#2072)
* refactor: move runtime resource mechanics out of daemon

* refactor: move Apple resource access out of daemon

* chore: enforce the terminal daemon platform boundary
2026-08-27 13:11:01 +02:00
Nicolas Bataille ed8d5efb00 fix(daemon): address cwd-scoped sessions by their store key, not their public name (#2068)
* fix(daemon): address cwd-scoped sessions by their store key, not their public name

An implicitly cwd-scoped session is NAMED `default` and STORED under
`cwd:<hash>:default`. Three surfaces built caller-facing text from the name, so
each pointed at something that does not exist:

- `DEVICE_IN_USE` reported `session "default"` and its recovery hint said
  `agent-device close --session default`. `--session` marks the session
  explicit, which disables cwd scoping, so following the hint addressed a
  different, absent session: `SESSION_NOT_FOUND`, and the device stayed held —
  the residual half of #2031 that #2057's superseded-daemon reconciliation does
  not cover.
- `session list` reported `sessionStateDir` and `runnerLogPath` under
  `<state>/sessions/default`, a directory that is never created; the session's
  real artifacts sit in `<state>/sessions/cwd_<hash>_default`, which is what
  `open` already answers with.

`SessionStore.entries()` exposes the key alongside the record, and
`buildSessionRecoveryHint` takes the session's address explicitly. Call sites
that only hold the session the current request named pass its name — the address
there — so their text is unchanged, as are explicitly named sessions everywhere.

Live before/after on an iOS simulator: `open` (cwd-scoped default), then
`open --session qa` on the same device. Before, the hint's own `close --session
default` answered `SESSION_NOT_FOUND` and the retry failed again; after, the
hint names `cwd:8bea844ab16aa9b3:default`, that close releases the device, and
the retry opens.

Refs #2031, #1394

* fix(daemon): thread the resolved session address through every recovery producer

The store key a request resolves — `cwd:<hash>:default` for an implicit
session — was known upstream but dropped before the selector-conflict and
lock-conflict producers, which then named `SessionState.name`. Both emitted
`close --session default`, the unreachable recovery this PR fixes for
DEVICE_IN_USE: `--session` marks the session explicit, so that command
addresses a different session.

`SessionRef` ({ address, session }) now carries the pair, so a recovery
producer cannot be handed a record whose address was never resolved.
`buildSessionRecoveryHint`, `assertSessionSelectorMatches` and
`applyRequestLockPolicy` take it; `prepareLockedRequestBinding` and the
Maestro replay route build it from the store key they already hold.

Replaces the broad `SessionStore.entries()` with the store's own narrow
lookups — `lookup`, `findByDevice`, `listRefs` — so callers receive the
session with its address instead of enumerating raw key/record tuples.

`session list` reports `address` alongside `name`, through the client
contract and serializer, so its discovery output names a value `--session`
accepts.

Regressions run the production routes, not the helpers: router-level
device-in-use, selector-conflict, lock-policy-conflict and `session list`
cases open an implicit session and read the address back off the store, plus
a typed-Maestro selector-conflict case. Each was observed red against the
pre-fix behavior it pins.

Refs #2031, #1394.

* fix(daemon): doctor names same-device sessions by address; green the fallow gate

- sessionChecks enumerated same-device sessions by SessionState.name:
  the printed close --session default was the exact unreachable
  recovery this PR removes elsewhere, the name-based dedupe hid two
  cwd-scoped default sessions from each other, and the evidence
  sessionStateDir pointed at <state>/sessions/default, which never
  exists. Candidates now come from listRefs() and report addresses.
- findByDevice joins values/delete in .fallowrc.json usedClassMembers
  (same resolution false positive on this class; the call site is
  session-open-execution.ts).
- the scoped-paths test narrows its response once instead of nine
  optional-chaining hops, which tripped the CRAP gate.

* style: format .fallowrc.json

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-27 11:06:32 +02:00
Michał Pierzchała 71214e11da refactor(runtime): close residue execution units (#2054)
* refactor(runtime): close residue execution units

* refactor(runtime): address residue ownership review

* fix(runtime): preserve viewport diagnostic log path
2026-08-27 07:46:25 +02:00
Michał Pierzchała 9f16fc885c refactor: migrate perf to device runtime (#2061) 2026-08-26 20:41:19 +02:00
Michał Pierzchała 33a816966c perf(apple): uninstall stale runner bundles concurrently (#2058)
* perf(apple): start stale simulator runner bundle uninstalls concurrently

cleanupStaleSimulatorRunnerBundles awaited each simctl uninstall
sequentially while discarding the results (best-effort cleanup). Run the
per-bundle uninstalls under Promise.allSettled like the sibling disposal
paths, and pin the concurrent start with a deferred-promise test.

* test(apple): split stale-bundle cleanup coverage out of the pinned runner-session suite

runner-session.test.ts is over the test-size tripwire and its pin may only
shrink. Move the three stale-bundle cleanup tests (boot availability,
best-effort stall, concurrent start) into a sibling file named for the
domain question, carrying the same seam scaffolding.

* test(apple): lower runner-session suite pin to bank the stale-bundle extraction

* test(apple): format stale bundle coverage
2026-08-26 17:43:16 +02:00
Michał Pierzchała 72cae2bc72 refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040) (#2050)
* refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040)

Moves src/platforms/apple/core/runner/ (34 modules + apple-runner-platform.ts and
the 30 runner test suites) into packages/platform-apple/src/runner/ — Apple
mechanics live in the Apple package. Host capabilities (exec, diagnostics,
retry, process probes, locks, Apple tooling, physical-device control) enter
through the package-owned AppleRunnerHost port; the root composition module
src/platforms/apple/core/runner-client.ts constructs the client exactly once
and re-exposes the bound operations under their historical names.

R13 admits the transitional state deliberately: the family exports its root
façade plus exactly the enumerated ./runner, ./runner/client, and
./runner/test-host subpaths; the ./runner façade subpath is the recorded #1983
seam for unmigrated root consumers; ./runner/client has one composition root
and ./runner/test-host one vitest installer; the runner subtree may own its
cache files and sockets while raw process primitives stay banned. When #1983
completes, the subpaths and every subtree exemption are deleted and the family
returns to a single implementation-lazy façade export.

* docs(adr): model the runner subtree as a durable platform-owned facet

Review correction on #2050: the sunset story attributed the runner-consumer
migration to #1983, which owns snapshot/presentation vocabulary — not the
runner's daemon/root consumers — so that event cannot delete the ./runner
subpaths or the subtree exemptions. Reword ADR-0019, R13, and the gate
comments: the facet is the intended ownership model, its seam is enumerated
and pinned (exact export list, one client composition root, one test-host
installer, raw-process ban, eager-closure pins), and the seam narrows only
if a real runner-consumer migration retires the direct consumers. The
declaration mechanism stays apple-specific until another family needs a
mechanics facet. No behavior change; identifiers and comments only.
2026-08-26 15:53:01 +02:00
Michał Pierzchała 74a70f1764 refactor: remove next-major compatibility surfaces (#2046) 2026-08-26 14:14:15 +02:00
Michał Pierzchała c77bc40d48 refactor(daemon): Wave 6 — migrate clipboard, app-switcher, trigger-app-event, settings, alert, react-native and capabilities onto request-bound runtimes (R55–R63) (#2021)
* refactor(daemon): migrate clipboard onto request-bound runtimes (R55)

Wave 6 unit 1 of the ADR 0019 platform-free daemon migration (#1739).
`clipboard` leaves the legacy dispatch projection: admission is now the
action-selected `readClipboard`/`writeClipboard` fact the parsed subcommand
names, and the only execution is that one bound operation.

- new `@agent-device/contracts/clipboard-runtime` facet, riding the existing
  `Interactor` seam through `interactor-operation-binding.ts`; read and write
  are separate cells because a provider can genuinely expose one half only.
- every owner states its own cells: Apple gains a `system/` facts module
  (simulator or the macOS host, matching the retired
  `supportsHostOrSimulatorSurface` closure), Android admits every real kind,
  Linux the desktop device, and HarmonyOS/Vega/web refuse -- none ever carried
  a bucket. Limrun reuses the local Android interactor and refuses on iOS;
  WebDriver rides interactor reachability like `back`/`home`.
- retires the `core/dispatch.ts` clipboard arm and handler, the descriptor's
  capability bucket and `dispatch` leaf, and the Apple plugin's clipboard
  admission closure. `handlers/session.ts` loses its inline handler (and its
  last `dispatchCommand`/`requireCommandSupported` imports) to the new
  `handlers/session-clipboard.ts`.
- `bindLocalInteractorOperationSet` collapses the byte-identical local
  interaction bind list Android and Linux each held a copy of.

Cutover row R55 with its retirement, admission-member and single-bind claims.

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

* refactor(daemon): migrate app-switcher onto request-bound runtimes (R56)

Wave 6 unit 2 of the ADR 0019 platform-free daemon migration (#1739).
`app-switcher` leaves the legacy dispatch projection: admission is the owner's
`appSwitcher` fact and the only execution is that one bound operation, resolved
by the generic route alongside back/home/orientation/tv-remote.

- new `@agent-device/contracts/app-switcher-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog.
- Apple states one springboard reading for `home` and `app-switcher` (parity:
  the retired `supportsAppAndDeviceLifecycle` closure gated both off the same
  per-AppleOS row, so macOS and watchOS refuse); Android admits every real kind;
  HarmonyOS admits both kinds, restating the retired overlay membership;
  Linux/Vega/web refuse. Limrun reuses the local Android interactor and refuses
  on iOS; WebDriver rides interactor reachability.
- retires the `core/dispatch.ts` arm, the capability bucket, the `dispatch`
  leaf, `HARMONYOS_SUPPORTED_COMMANDS` membership, the Apple plugin closure, and
  the now-readerless `appAndDeviceLifecycle` row in the per-AppleOS table.
- router tests that used `app-switcher` as their legacy-dispatch stand-in move
  onto bound operations; the typed-error `supportedOn` test moves to `perf`, the
  one command that keeps a capability-matrix row after this wave.

Cutover row R56 with its retirement, admission-member and single-bind claims.

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

* refactor(daemon): migrate trigger-app-event onto request-bound runtimes (R57)

Wave 6 unit 3 of the ADR 0019 platform-free daemon migration (#1739).
`trigger-app-event` leaves the legacy dispatch projection: admission is the
owner's `triggerAppEvent` fact and the only execution is that one bound
operation.

The split follows ADR 0019 §2 — a facet input names no command, request, or CLI
flag. The event name pattern, the payload size limit, and the per-platform
`AGENT_DEVICE_*_APP_EVENT_URL_TEMPLATE` are daemon policy and stay in
`core/app-events.ts`; what reaches the owner is a resolved URL to open. They
also stay downstream of admission, where the retired `dispatchCommand` ran them,
so an unsupported device still reports its unsupported cell rather than an
argument error.

- new `@agent-device/contracts/app-event-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog.
- Apple admits every leaf with a constructible interactor (no closure ever gated
  this command beyond its bucket), Android every real kind, and
  Linux/HarmonyOS/Vega/web refuse. It is the one system leaf both Limrun legs
  serve, since each implements `open`; WebDriver rides interactor reachability.
- retires the `core/dispatch.ts` arm and handler, the capability bucket, the
  `dispatch` leaf, and the session route's last
  capability-gate-then-`dispatchCommand` thunk: every leaf on that route now
  supplies a bind-and-execute thunk.
- the end-to-end delivery tests keep their shell-level assertions and move onto
  the migrated composition.

Cutover row R57 with its retirement and single-bind claims.

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

* refactor(daemon): migrate settings onto request-bound runtimes and retire the legacy dispatcher (R58)

Wave 6 unit 4 of the ADR 0019 platform-free daemon migration (#1739). `settings`
was the last `DISPATCH_HANDLERS` arm, so this change closes the command and
retires the legacy command dispatcher whole.

- new `@agent-device/contracts/settings-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog. What reaches the
  owner is its own settings vocabulary (setting, state, resolved app id, typed
  coordinates); the CLI parse, the macOS setting-name gate, the clear-app-state
  app-id check and the coordinate typing are daemon policy and stay daemon-side,
  downstream of admission where the retired leaf ran them.
- Apple shares clipboard's exact host-or-simulator reading (the retired
  admission intersected the `settings` bucket with the same
  `supportsHostOrSimulatorSurface` closure); Android admits every real kind;
  HarmonyOS matches its retired overlay membership; Linux/Vega/web refuse.
  Limrun splits Android-reuse / iOS-refusal like `app-switcher`; WebDriver
  refuses unconditionally, since its interactor declares settings unsupported.
- retires `dispatchCommand`, `dispatchWithInteractor`, `dispatchKnownCommand`,
  `DISPATCH_HANDLERS`, `listRegisteredDispatchCommandNames`, and the request
  router's `executeGenericPlatformCommand` fallback. `core/dispatch.ts` keeps
  only `dispatchGestureViewport`, whose last consumers are replay/test.

Retiring the dispatcher surfaced two callers broken since Wave 5 moved `press`
onto a bound runtime: react-native overlay dismissal and the opt-in interaction
no-change retry both called `dispatchCommand(device, 'press', …)`, which has
thrown `INVALID_ARGS: Unknown command: press` on main since R48. Both now run
the same bound `tapPoint` every other touch leaf uses. The retry declares its
own callback seam rather than importing runtime admission, so the policy stays
readable without the binding stack — and that inversion, plus the dispatcher's
retirement, drops the largest type-level import cycle from 25 files to 21.

Cutover row R58 with its retirement and single-bind claims.

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

* refactor(daemon): migrate alert, react-native and capabilities onto facts (R59/R61/R63)

Wave 6 units 5, 7 and 9 of the ADR 0019 platform-free daemon migration (#1739),
plus the residue reclassification the tracker asks for as an analysis task.

R59 `alert` — new `@agent-device/contracts/alert-runtime` facet with four
action-selected legs (`readAlert`, `awaitAlert`, `acceptAlert`, `dismissAlert`)
on the shared `Interactor` seam. The daemon route admits and binds exactly the
leg the parsed subcommand names, and the poll and retry windows move to the
owners with it: how long a transient sheet takes to appear, and how many times
to re-ask a runner that says it is not there yet, are family mechanics, not
request policy. `src/platforms/apple/alert.ts` now holds the Apple windows
verbatim (with the macOS-helper / XCTest-runner split), and Android's legs read
the same presented tree `snapshot` publishes, which is why their occlusion
reading still holds.

Apple's cell is the retired `supportsAlertSurface` closure restated as facts —
the host-or-simulator reading widened by physical iOS — and that closure was the
per-AppleOS capability table's last reader, so `src/platforms/apple/capabilities.ts`
goes with it.

R61 `react-native` — the command's device work moved onto a bound `tapPoint`
with R48; this retires the capability gate that still stood in front of it and
moves admission ahead of the observing capture, so an owner that cannot dismiss
an overlay refuses without first spending a snapshot on it. That exposed a real
defect: the request handler chain never forwarded the request's runtime bindings
to this route, so the dismissal leg had been reaching a missing gateway ever
since R48 — only the no-overlay-detected path returned early enough to hide it.
Fixed, with a chain-level regression test.

R63 `capabilities` — the projection now reads each command's own declared
`platformExecution` uses instead of a hand-written map plus a "no capability
bucket means supported everywhere" fallback. That fallback is what let a stopped
Android AVD advertise `snapshot press fill` it cannot run, and a Vega VVD
advertise every migrated command; both collapse to the fact-derived set here.
The command itself executes nothing on a device, so it declares `none`.

Residue: `batch`, `debug` and `events` reclassify to `none` — each reaches no
device and delegates nothing that does. `replay`/`test` keep their gesture
viewport and boot-diagnostics edges, `daemon`/`web` hold platform imports in
their own CLI modules, and `react-devtools` still injects device-runtime
`runtime`, so all five stay `legacy`.

Cutover rows R59 and R61 with their retirement and single-bind claims.
Descriptors: 32 legacy at the wave checkpoint, 9 now.

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

* fix(daemon): restore two settings/alert sequences the migration had shifted

Self-review of the Wave 6 diff against `origin/main` found two places where the
migrated routes were faithful in what they did but not in when:

- `settings` typed its location coordinates before expiring the ref frame, so a
  request that failed on a bad coordinate no longer expired it. The retired route
  expired the frame first, then emitted its diagnostic, then typed the
  coordinates inside the leaf. Same order again.
- `alert` narrowed a frontmost-app session to "no bundle" in the daemon, which
  also stripped the bundle from the XCTest runner leg. That narrowing was only
  ever the macOS helper's, and it already lives in `platforms/apple/alert.ts`;
  the runner leg gets `session.appBundleId` unconditionally again, pinned by a
  test.

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

* fix(daemon): address adversarial review of the Wave 6 cutovers

Three independent reviews (behavior parity, correctness, ADR 0019 conformance)
ran against the branch. What they found, and what changed:

Correctness

- The R48 retry seam was unreachable. `captureSnapshot` builds it from the
  request's runtime bindings, but no caller forwarded them, so every retry
  resolved to a skip. The `snapshot` route now threads `inspectFacts`/
  `bindDevice` through `createSnapshotRuntime` and the daemon snapshot backend
  down to the capture.
- A retry tap that rejected escaped the capture it was decorating and turned a
  plain `snapshot` into an error. It is caught and reported as a skip, matching
  what the seam's own contract already claimed.
- The attempt is spent before the device work again, as the retired route did,
  so an owner that fails mid-flight cannot be re-attempted from a full budget.
- `react-native dismiss-overlay` reached its required `tapPoint` through `?.`
  and answered `dismissed: true` when the operation was absent. It refuses.
- `factOwnedCapabilityAvailable` indexed the facts map unguarded, and treated an
  empty `required` as proof (`[].every` is vacuously true). Both fail closed.

ADR 0019 conformance

- §6 forbids a `none` descriptor from binding a device, and `capabilities` bound
  three times to answer `logs`/`network`/`record`. Every owner composes a
  binding's facts with the same function `inspectFacts` calls, so those probes
  read back values the single inspection already carries — at the cost of a
  device claim on a read-only query. They are gone, and with them the last three
  empty-`required` admission uses.
- §9 is one admission per handler; the retry tap re-admitted on every retry
  round. It memoizes per device.
- `installFamilyCapabilityAvailable` was scaffolding this wave was scheduled to
  retire: the general projection returns the same verdict for all four
  install-family commands. Deleted.

Leftovers the cutovers created

- `requireCommandSupported` lost its last production caller when R56 migrated
  `app-switcher`: every generic-route command is admitted from owner facts
  before the dispatcher runs. The dead arm, the function, and
  `commandUsesDeviceRuntimeExecution` are removed.
- `CommandDispatchFacet`, `descriptor.dispatch`, and `explain`'s `dispatch=`
  field described a dispatcher R58 deleted.
- `request-router-android-modal.test.ts` asserted on a `dispatchCommand` mock
  whose module export no longer exists, so three assertions were vacuous.
- `generic-route-runtime-completeness.test.ts` now exists — a comment claimed it
  did. It pins the routing table as total over the generic route.
- Comments and test names describing the retired dispatcher, the deleted AppleOS
  capability table, and a react-native regression that never shipped.

Also records two deliberate provider cell changes the migration made (physical
Apple `clipboard` admitted, provider `alert` refused) and the react-native
widening to Linux, web and HarmonyOS, and drops a scratch probe file that was
committed by accident.

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

* fix(apple): type the alert-absence retry instead of matching error prose

Review blocker 1 on #2021. The Apple alert legs decided retry and hint
eligibility by substring-matching error messages for "alert not found" / "no
alert", and `alert wait` swallowed *every* read failure. A dead runner, an
unreachable macOS helper or a canceled request was therefore spent as poll
budget and finally reported as `alert wait timed out`, hiding the real cause.

Both backends now state absence as typed evidence:

- The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is
  diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as
  `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used.
- The macOS helper adds `reason: "alert-not-found"` to its JSON error details,
  which the helper client already forwards verbatim.

`isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws
anything that is not a typed absence instead of polling through it, and the
scoped-snapshot fallback hint attaches to typed absence alone.

The three tests the review asked for, plus coverage the daemon-altitude copies
could not express: a non-absence failure propagates immediately from `wait`; an
action does not retry a failure whose message merely reads like an absence; the
macOS helper's typed reason is retried like the runner's. The daemon-level
non-absence test moved to the family suite that owns this policy since R59,
lowering that file's size pin.

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

* fix(runtime): admit clipboard and provider operations from what execution checks

Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator:
`capabilities` advertised `clipboard`, then `clipboard read` failed with
`UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this
device.` Admission and execution were consulting different authorities, which
ADR 0019 §2 forbids — a bound operation must already be admitted.

Android. `cmd clipboard` has no shell implementation on every build, and the
retired bucket admitted both halves on every real Android kind, leaving the leaf
to discover the refusal after the fact. Support is now a fact: the owner probes
once per device (cached for its lifetime — a build's shell command set cannot
change while the device is up) and states `owner-capability-missing` when adb
names the condition. The probe is definitive in one direction only: adb saying
so means unsupported, a probe that cannot run means unknown, and reporting
unknown as unsupported would hide a working clipboard behind a transport
hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support`
so admission and the leaf's own defense-in-depth check cannot drift apart.

Cost, stated plainly: the first facts inspection per device now spends one adb
round trip, including for requests that never touch the clipboard.

WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`,
while fact generation admitted from interactor reachability alone — so a
provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }`
was admitted and then thrown out of. The declared capability map is now an input
to fact generation, and the refusal carries the map author's own note. Applied to
every operation with an unambiguous capability key, not just the two named in
review: the mechanism is identical and a half-applied fix would leave the same
defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is
unchanged by default — every one of those is `supported` or `partial` in the base
map — so only an explicit override bites. `focus`, the gesture tiers and
`trigger-app-event` keep reachability: no capability key maps to them 1:1.

Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in
the cutover table into one parameterized factory (second review point).

Parity tests: an Android build reporting either unsupported-shell phrasing, the
probe cache, an adb failure staying admitted, and a WebDriver override refused at
admission for each keyed operation.

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

* refactor(layering): split the Wave 6 cutover rows into a sibling module

Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325
lines, past the point where one read covers it.

Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are
spread back in, leaving the table at 1,095 lines. The split is by wave because
that is how these rows are retired: a wave's rows are deleted together once the
ADR declares its commands' migrations closed, and deleting a whole file is a
cleaner end than excising a run of literals from the middle of a larger one.

`retiredDispatchProjectionProof` moves to the shared extensions module, since
both tables now use it — the main table for `snapshot`/`diff`, the sibling for
its own eight.

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

* fix(android): never fabricate clipboard availability from a failed probe

Review blocker on #2021. The probe I added had a `catch { return true }`, then
cached that result by device id for the runtime owner's lifetime. A transient
adb offline or timeout therefore made `capabilities` advertise the clipboard on
a build with no clipboard shell — recreating the exact lie the fix was for, and
pinning it for the rest of the session. A test locked the behavior in.

Support is now a typed verdict with three states, because "we could not ask" is
not "it works": `supported | unsupported | probe-failed`. Only a definitive
answer is cached; `probe-failed` refuses conservatively with a hint saying
support could not be determined, and is deliberately not remembered, so the next
inspection asks again.

The same change repairs the ownership boundary. Turning raw adb stdout/stderr
into a verdict is Android tool knowledge, so it belongs to the Android owner, not
to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now
carries the typed union alone. The parser returns to `src/platforms/android/adb.ts`
and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport`
that hands owners the verdict. That also settles which Android home owns it:
R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`,
so a parser shared between the package and the root leaf cannot live in the
package either.

Tests now cover the failure path the previous ones locked the wrong way: a failed
probe refuses instead of admitting, its refusal says it could not determine
support rather than claiming the build lacks it, and it is not cached — a second
inspection re-probes and admits once the device answers.

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

* refactor(contracts): declare each interactor operation once

Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same
operation set three times — a name tuple, a complete local binder map, and a
complete provider binder map — and each facet carried a mirrored
`bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was
which interactor source to use and which label a refusal names.

There is now one row per operation, carrying its facts key, its provider refusal
label, and the facet's own executor. The local/provider split lives in the two
adapters, which differ by exactly the thing that differs: the interactor source.
Adding an operation is adding one row.

Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten
facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases
that existed only to be re-exported. Kept: every facet's typed executor, now
exported as its binding surface.

Net −563 production lines in `packages/contracts`.

Two consumers moved onto the catalog's public entry point rather than keeping a
private path to a single operation: the app-event delivery test and the provider
scenario fixture, whose two hand-bound keyboard legs are now whichever legs its
facts admit. Each facet's tests spell out the composition the retired wrappers
performed, so every assertion still exercises one executor reached through one
source.

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

* fix(android): let only a clean adb exit prove clipboard support

Third review P1 on #2021. The typed verdict landed one layer too high. The
adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a
non-zero exit comes back as an ordinary result rather than a throw — and the
only thing standing between that result and `supported` was the missing-shell
prose check. A device that had gone offline, was unauthorized, timed out, or
failed for any other reason produced none of that prose, so it fell through to
`supported` and was then cached by device id for the runtime owner's lifetime.
The `catch` I added guarded the one path adb almost never takes.

Each adb outcome now proves only what it can:

- `exitCode === 0` is the sole evidence of support, because it is the only
  result that shows the command ran.
- The recognized missing-shell prose is the sole evidence of absence, and is
  read before the exit code — adb reports that condition non-zero, so checking
  the code first would turn every honest `unsupported` into a refusal.
- Everything else — non-zero without that prose, and the transport throw — is
  `probe-failed`, which admission refuses and the cache does not remember.

The package tests mocked the typed verdict, so they sat downstream of the bug
and could not see it. The regression is therefore at the adapter, over the raw
adb result: four planted reds (offline, unauthorized, device-not-found, generic
failure) that all returned `supported` before this change, plus the two
definitive verdicts and the ordering case that keeps `unsupported` reachable.

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

* fix(android): never read adb's refusal prose out of the clipboard's contents

Fourth review P1 on #2021, and a second instance of the same bug it names.

The previous fix read `isClipboardShellUnsupported(stdout, stderr)` before the
exit code. On a *successful* `cmd clipboard get text`, stdout is the clipboard's
contents — arbitrary user text. Anyone who had copied "unknown command" or "no
shell command implementation" (from a terminal, a bug report, this repo) had
their own working clipboard classified `unsupported`, and the runtime owner
cached that for its lifetime. Ordering prose ahead of the exit code to keep
`unsupported` reachable traded one wrong admission for another.

The exit code is decisive on its own when it is zero, so it goes first. Only a
call that failed can carry prose about the call itself, which makes the missing-
shell phrases meaningful on non-zero exits alone:

    if (result.exitCode === 0) return 'supported';
    return isClipboardShellUnsupported(...) ? 'unsupported' : 'probe-failed';

`isClipboardShellUnsupported` now states that precondition, because reading it
on a successful call is exactly the mistake to prevent.

The same defect was already shipped in the helper's other caller.
`runAndroidClipboardShellCommand` in `src/platforms/android/device-input-state.ts`
has checked the prose before the exit code since #1950, so `clipboard read` on a
clipboard holding either phrase threw `UNSUPPORTED_OPERATION` — telling the user
their device does not support a clipboard it had just read correctly. It is not
this wave's code and not reachable from the migration, but it is the same helper
misused the same way, and documenting a precondition while leaving a caller that
violates it invites the next regression. Repaired here, with the failure ordering
otherwise unchanged: a non-zero exit still reports missing-shell as
`UNSUPPORTED_OPERATION` and anything else as the adb result error.

Both repairs are pinned by regressions that fail against the code they replace:
four exit-0 cases at the adapter (verified red against the ordering this commit
removes), and three at `readAndroidClipboardWithAdb` (verified red against
`origin/main`) covering contents that look like a refusal, a genuine missing
command, and an unrelated non-zero failure.

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

* fix(cli): bring the workflow help card back under its size budget

`Coverage (2)` has been red on `main` and on every PR branched from it since
#2020, which replaced three short Bootstrap lines with one longer line carrying
the new selection semantics. It updated the content matcher for that line but
not the size assertion beside it, so the card went to 9003 bytes against the
`< 9000` both `cli-help.test.ts` and `cli-help-topics.test.ts` enforce.

Nothing #2020 added is removed here — all of it is pinned by the matcher it
shipped, and it is the sentence agents most need. The bytes come back from a
clumsy repetition elsewhere in the card, where "settle" named itself twice in
one clause:

  ... only when you did not settle, settle reported not settled, or ...
  ... only when you did not settle, it reported not settled, or ...

which reads better short and puts the card at 8999.

That is one byte inside the budget, which is the real finding: the card has no
slack left, and the next sentence anyone adds re-opens this. The durable fix is
a base-owner call between raising the budget and moving a block down into its
sub-topic — the mechanism the card already uses, and which its own test
documents. Flagged on #2021 rather than decided here.

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

* test(cli): raise the workflow help-card budget to 9100

The card is a curated agent-facing reference, and #2020 grew it for a good
reason: the selection semantics it added are what an agent needs to predict
which device a bare `open` picks. Holding that content to a limit set before it
existed just moves the cost onto whoever writes the next sentence.

9100 is headroom, not a target. The previous commit left the card at 8999 of
9000 -- one byte -- which is not a state anyone should have to work in, and I
had already established there is no slack left to reclaim: no trailing
whitespace, and the only repeated runs are the deliberate column alignment in
the Escalate footer. Trimming further would have meant deleting content the
tests pin as load-bearing.

This is explicitly interim. The card is ~9KB of dense prose in one string, and
the real answer is to move a block down into its owning sub-topic -- the
mechanism the card already uses and its own test documents ("Deep content moved
out of the compact card, not deleted"). Raising the ceiling buys room to do that
deliberately instead of under a red CI.

Both enforcement sites move together, since they measure the same card through
different surfaces: `cli-help.test.ts` reads it through the CLI, and
`cli-help-topics.test.ts` through `usageForCommand`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 17:47:35 +02:00
Michał Pierzchała 52ac5da091 fix(daemon): stop branch-named daemon before replacement (#2015)
* fix(daemon): recognize branch-named daemon entries

* fix(daemon): require process identity for takeover

* fix(daemon): preserve shutdown cleanup identity
2026-08-25 13:42:34 +00:00