Commit Graph

221 Commits

Author SHA1 Message Date
agent 05e6d8830d fix(layering): let a cutover row state a data-only admission retirement
Review blocker on #1883: R37 claimed `legacyRetirement.routeNames:
['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT']`. Neither identifier has
ever existed. They satisfied the non-empty shape check while proving nothing —
the vacuous registry claim AGENTS.md warns about, and a green gate that would
stay green if the deletion were reverted.

The cause was the model, not the row. Every `LegacyRetirementClaim` form names
something that must NOT exist, which a row can always satisfy by inventing a
name. `is` retired no module, route, or dispatch projection because it had none:
its legacy admission was a capability bucket plus membership in two static
platform command sets, so its real retirement is a DATA deletion the model could
not express. Rather than patch around that with sentinels or a per-command
policy file — both forbidden by the playbook — this generalizes the model.

`staticCommandSets` names the sets themselves and is proven from both sides:
each must still be DECLARED in production source, and must no longer list the
command. A fictional set fails the first half; a skipped deletion fails the
second. That is what an identifier-shaped claim cannot state.

R37 now claims HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS, which is the
deletion it actually performed.

Planted red, both halves, against the real gate:

  [R37 is-runtime-cutover] 2 violation(s):
    (is cutover row):1 — claims retired static command set
      'WEB_QUERY_COMMANDS_WITH_IS', which no production source declares
    (is cutover row):1 — claims retired static command set
      'HARMONYOS_IS_SUPPORT', which no production source declares

  [R37 is-runtime-cutover] 2 violation(s):
    src/core/capabilities.ts:59 — static command set WEB_QUERY_COMMANDS still
      admits is

so the exact claim that shipped is now rejected by name, and so is restoring the
membership it claims to have removed. Mechanism cases live with the other
planted-row tests; layering goes 177 -> 181.
2026-08-19 17:44:57 +02:00
agent b1fa2b5aae refactor: migrate is to the request-bound device runtime
`is` declares the shared selector capture use, admits once from exact owner
facts, refuses before binding, and binds exactly once. Its capability bucket,
the static HarmonyOS/Web command sets that augmented it, and
`requireCommandSupported` admission for `is` are gone; `'is'` leaves the
`createSelectorRuntime` capability union.

Admission now runs BEFORE the direct-iOS selector fast path. ADR 0019 requires
resolve -> admit -> bind before anything in a `device-runtime` command's request
path reaches the device, so that query becomes a fast path *within* an admitted
request rather than a way around exact-owner facts. The rule is documented once,
on `createBoundSelectorRuntime`, replacing the two duplicated call-site comments
`get` and `is` were each carrying.

Declared behaviour change: `is` takes the active-app plan split, so the facts
decide per family. On iOS `appBundleId` is the XCUITest attach target — with no
tracked app the runner's own process comes to the foreground, displaces the app
under test, and the capture then answers confidently about the runner's own
blank screen. An iOS `is` on a session with no tracked app is now a typed
SESSION_NOT_FOUND refusal carrying the `open` hint. Refusing beats
displacing-and-lying. Android captures the real launcher in that state and is
unchanged, which is what the platform facts already encoded.

The two Apple watchOS cells move from capability-admitted-then-runner-failure to
a typed unavailable refusal, the same classification snapshot, diff, and get
already landed.

R37 is the new parametrized cutover row. `find` keeps `createSelectorRuntime`
and its `requireCommandSupported` call, so `captureData` stays optional and
`captureSnapshotWithInteractor` stays: this unit is not the last selector unit.
2026-08-19 17:37:30 +02:00
agent 51e9b7d330 refactor: retire the read dispatch alias across both selector consumers
Read-only `find` now constructs a BOUND selector backend, so `get text` and
`find <q> get text` execute the same bound `readTextAtPoint` instead of one
binding it and the other dispatching the legacy `read`. This moves find's READ
LEG only: find's descriptor stays LEGACY_PLATFORM_EXECUTION and it claims no
cutover row.

With no consumer left, the whole chain goes: the `read` registry entry and its
`dispatch: {}` projection, `DISPATCH_HANDLERS.read`, `handleReadCommand`,
`interaction-read-legacy-dispatch.ts`, and the duplicate platform reader
branches it carried. `read` was the only `dispatch-alias` descriptor, so that
catalog group goes too.

Deleting the registry entry drops 'read' from DescriptorDispatchCommandName,
which makes a surviving DISPATCH_HANDLERS.read a compile error rather than
something R36 has to police. R36 now claims the retirement it can prove.

`find.test.ts` is over the size tripwire, so its handler invocation is
extracted to find-handler-fixture.ts and the pin lowered 1237 -> 1221.
2026-08-19 17:24:10 +02:00
agent 50549ad336 feat(daemon): land the selector capture seam with get as its first consumer
Takes ownership of the request-bound selector capture seam from #1876, which
cannot ship standalone: with find's cutover deferred it had no consuming
command (ADR 0019 §10) and was not dead-code clean (check:production-exports
19 -> 20). `get` is its first consumer, so it lands here.

Adopts find's handoff as given. The one shape change, approved by the
coordinator: the selector family gets its own capture uses carrying a PREFERRED
`readTextAtPoint`, declared ALONGSIDE the snapshot uses so `snapshot`/`diff`
keep binding exactly what they bind today. The read is surfaced through the
existing arms of `bindSnapshotCaptureRuntime`, reusing the same
selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors — no second
plan-to-operation dispatch.

`get` now runs through `createBoundSelectorRuntime`; `resolveBoundGetRuntime`
and its test are deleted as superseded, and `'get'` leaves the
`createSelectorRuntime` capability union.

The legacy read adapter survives for `find <q> get text` and is selected by
which command constructed the runtime — never by failure, family, environment,
or flag — so `get` cannot reach it. It retires in find's cutover, where the
last consumer moves.
2026-08-19 17:24:10 +02:00
agent 693de9d133 refactor: migrate get to the request-bound device runtime
`get` declares `elementReadRuntimeUse` (required `captureSnapshot`, preferred
`readTextAtPoint`), admits once from exact owner facts, refuses before binding,
and binds exactly once. Its capability bucket, the static HarmonyOS/Web command
sets that augmented it, and `requireCommandSupported` admission for `get` are
gone; `'get'` leaves the `createSelectorRuntime` capability union.

The neutral `readTextAtPoint` operation replaces the branch-per-family legacy
`read` dispatch on the `get` path. Every local family and both providers now
classify it exhaustively — Web, HarmonyOS, Vega and every provider row report it
unavailable, which is behaviour-preserving because the legacy dispatch had no arm
for them and threw on every call before falling back.

R36 is the new parametrized cutover row.
2026-08-19 17:24:09 +02:00
agent 3bf786f5f8 refactor(daemon): one capture-input builder and one admit-then-bind step
Behaviour-neutral. No descriptor changes platform execution, the cutover table
is untouched, and no contract surface is added.

- buildRuntimeCaptureInput moves to its own module so every request-bound
  capture consumer builds CaptureSnapshotInput one way.
- The admit-then-bind sequence in the snapshot/diff resolver becomes one named
  step, ready for the selector units' second caller.
- handlers/find.ts splits into focused target-capture and match-resolution
  concepts (600 -> 346 lines); behaviour unchanged.
2026-08-19 17:01:19 +02:00
Michał Pierzchała d07b837621 test: classify the runner XCTests — pure decisions to a macOS host lane, simulator semantics gated os(iOS) (#1781 A7) (#1861)
Every declared AgentDeviceRunnerUITests method now belongs to a lane, and
the #if guard is the classification: AGENT_DEVICE_RUNNER_UNIT_TESTS alone
means a pure runner decision (runs on the macOS host on every PR — ci.yml's
existing compile job now executes the bundle it builds), '&& os(iOS)' means
runner/XCTest semantics (simulator lanes only). check:xctest-selection
evaluates the guards per platform, derives each lane's reach, and fails on
a flagged identifier that is undeclared or uncompiled on that lane, on a
declared test no lane reaches (found the two tvOS-only tests, dark since
birth — widened to os(tvOS) || os(macOS)), and on testCommand reaching any
lane. The host and nightly lanes assert executed == derived reach, so a
missing -D flag or a guard that compiles a file out reads red, not as a
smaller green. One duplicate test deleted (sparse-verdict assertions folded
into its twin).
2026-08-19 13:59:45 +02:00
Michał Pierzchała 99754dc69c fix(layering): resolve relative imports inside workspace packages referencing #1781 (#1872)
* fix(layering): resolve relative imports inside workspace packages

resolveTargetFile() dropped any relative import whose resolved path
didn't start with src/. Since #1490 W0 added packages/*/src/** to the
source set, every intra-package relative import (e.g. a facade
re-exporting a sibling file) was silently invisible to the layering
graph — R4 value-cycle rejection and depgraph reverse reachability
both stopped at the package facade.

Resolve relative specifiers that land under packages/<name>/src/ too,
while still refusing anything outside src/ and packages/*/src/.

Verified against the real tree: 448 previously-invisible value edges
and 436 type-only edges now resolve, but none of them close a new R4
value cycle or grow the R9 type-cycle SCC, so the R6/R9 baselines are
unchanged.

Refs #1781

* style: apply oxfmt to regression test
2026-08-19 13:54:34 +02:00
Michał Pierzchała 6984a1e095 fix(layering): list the whole zone when R10's type-cycle ceiling is exceeded (#1852)
* fix(layering): list the whole zone when R10's type-cycle ceiling is exceeded

The per-zone R10 violation named members.find(<zone match>) — the
alphabetically-first zone member, a file that had been in the cycle all
along — so the +1 in #1825 x #1779 was found only by diffing
largestTypeCycleMembers between commits. The ceiling records a count, not
a membership, so the gate cannot name the joining file; it now lists every
member of the over-budget zone and annotates the ceiling table instead.

Closes #1837

* fix(layering): state the zone overflow in net terms

Review nit: the overflow is net growth over the ceiling, not a join count
(two joins and one departure print "1"), so the message no longer claims N
members joined.
2026-08-19 11:08:02 +02:00
Michał Pierzchała f3d5b3d92c refactor(daemon): admit-before-bind as an admitted-plan token; retire the R32 syntax policy (#1841)
* refactor(daemon): admit-before-bind as an identity-keyed admitted-plan token; retire the R32 syntax policy

admitRuntimePlan (was inspectRequiredRuntimeUse) takes the plan and, on
success, mints an AdmittedRuntimePlan: a nominal class instance with nothing
readable on it. Its payload — a frozen copy of the device the facts were read
for, and the plan — lives in a module-private WeakMap keyed by the token's
exact identity, and the only way to read it is unwrapAdmittedRuntimePlan,
which refuses anything not minted here. The snapshot owning interface
(resolveBoundSnapshotCaptureRuntime, #1847) admits through it and its private
binder takes only the token: no bare plan, no separate device, and no
look-alike — a spread lacks the #private member (not assignable), a Proxy
around a real token types as the token but is a different identity (refused
at unwrap), Object.assign/defineProperty throw on the frozen instance, and the
class value is not exported so its constructor is not nameable.

That retires scripts/layering/runtime-command-cutover-snapshot.ts — R32's
per-command AST policy (call-shape recognition of the admission and a text
sniff for a local admission) — and the source-regex test beside the descriptor
tests. The generic row keeps retirement, narrowing, and singular execution;
the manufactured-proof column now also rejects casts to AdmittedRuntimePlan.

Planted reds: token degraded to a plain public shape → 2 unused
@ts-expect-error directives; unwrap reading the token surface via getters →
the Proxy regression fails; getter-based branded literal → the runtime
retarget test fails.

* docs(agents): the ADR 0019 unit checklist teaches the shipped admission API

#1836 documented inspectRequiredRuntimeUse with a forward note pointing here;
this PR makes admitRuntimePlan real, so the row now teaches it plus the
identity-keyed unwrap the binder uses, and points at the shared snapshot/diff
owning interface as the model.
2026-08-19 10:46:25 +02:00
Michał Pierzchała 37b1bc8cbd refactor: migrate viewport to request runtime (#1864)
* refactor: migrate viewport to request runtime

* fix: preserve viewport cutover evidence
2026-08-19 10:38:27 +02:00
Michał Pierzchała 3f0f706f0b refactor: migrate diff to request-bound runtime (#1847) 2026-08-18 19:40:14 +02:00
Michał Pierzchała f03c0309a1 fix: derive iOS transition snapshots from visible presentation (#1831)
* fix: project iOS transition semantics

* fix: derive iOS transition semantics from visible state

* fix: preserve iOS presentation context for scoped snapshots

* fix: confirm broad iOS transition settlement

* ci: run coordinate input regression on pull requests

* test: mock migrated snapshot capture seam

* fix: confirm transitions across snapshot backends

* fix: arm transition confirmation after first capture

* fix: settle against immutable action baseline
2026-08-18 17:53:23 +02:00
Michał Pierzchała 6a8beb653e feat(mcp): compact server instructions in both eras + MCP-only help tool (#1839)
* feat(mcp): compact server instructions in both eras + MCP-only help tool (#1833)

MCP-only clients got no workflow guidance: server/discover carried two
sentences, legacy initialize carried nothing, and the CLI guides
(agent-device --help, help <topic>) were unreachable over MCP.

- MCP_SERVER_INSTRUCTIONS: one MCP-phrased workflow card (<2 KB, the
  Claude Code truncation limit) returned by server/discover and legacy
  initialize alike.
- help tool, router-owned (not a command descriptor): no topic -> the CLI
  decision card; topic -> agent-device help <topic|command> text, prefixed
  with the one-line CLI->tool-property mapping; unknown topic -> isError
  listing the topics. listCommandTools() stays descriptor-only for the AI
  SDK; the router composes descriptors + help.
- Move src/cli/parser/cli-help{,-overview}.ts to src/cli-schema/ so
  src/mcp (rank 3) can import the renderers without a layering back-edge
  into src/cli (rank 6).

* fix(mcp): name terminal-only commands in help guides; colocate cli-help tests with their sources

- The MCP guide preamble claimed every `agent-device <command>` line is a
  tool of that name; `help web` tells the reader to run `web setup` /
  `web doctor` and no `web` tool exists. The preamble now lists the exact
  CLI-only set (listCliCommandNames minus listMcpExposedCommandNames) —
  derived, not scanned out of prose where `device`/`web` are ordinary
  words. Regression: help web names `web` as terminal-only, and the listed
  set equals the registry difference.
- cli-help-*.test.ts move from src/cli/parser/__tests__ to src/cli-schema/
  to mirror the moved sources.

* perf(mcp): tighten the guide card, tool description, and preamble

Instructions card 1572 -> 1378 bytes (paid every session), tool
description and preamble trimmed, HELP_TOOL built once as a const.
Bundle delta vs main 3189 -> 2715 bytes; the remainder is the guide text
itself, which the bundle carried in no MCP-phrased form before.
2026-08-18 17:48:36 +02:00
Michał Pierzchała 0fb38f1da2 test: prune abandoned test-run tmp directories at run setup (#1834)
* test: prune abandoned test-run tmp directories at run setup

A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job)
left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which
runs after test:unit in check:unit — flagged every dead-pid directory it
found. It could not tell this run's leak from a historical one, so one killed
run made every later, otherwise-green gate on the host fail.

Both TMPDIR redirection entry points (the Vitest global setup and the
node --test wrapper) now prune dead-pid run directories before creating their
own, printing one [tmpdir] line when they did; the post-run check keeps its
semantics and can now only ever name the run that just finished. Live owners
(a concurrent run in another worktree) are never touched.

The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the
liveness classification, so the setup can import the prune without a cycle.

* test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs

Review (P1): owner-pid liveness alone would prune a directory out from under
the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks,
or a daemon a test spawned all keep running with that TMPDIR. The liveness
model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ
on Linux) and treats a run directory as live while its owner pid is alive OR
any process's TMPDIR points into it; both the prune and the post-run leak
check use it. Regression: a wrapped probe spawns a detached long-lived child,
only the wrapper is SIGKILLed, the next prune preserves the directory; after
every consumer exits, the next prune removes it. Planted red with owner-only
liveness: the orphaned directory is pruned.
2026-08-18 17:48:12 +02:00
Michał Pierzchała 423927fdd8 chore(mutation): shrink to report-only — drop the ratchet, baseline and graduation (#1457, #1781) (#1828)
* chore(mutation): shrink the lane to report-only (#1457, #1781 wave 2)

The mutation harness's two real catches (#1474, #1475) both came from humans
reading the weekly score report. The ratchet half never operated: the baseline
was committed exactly twice (8cce0ef6b, 60400d04b), both times with
`stableRuns: 0, gating: false`, and was never updated after the very fixes it
triggered — the weekly job computed a new baseline and then `git checkout --`d
it, uploading a proposal nobody applied in 3+ weeks. A gate nobody arms is
harness weight; the report is the part that paid.

Deletes ratchet.ts + ratchet.test.ts, mutation-baselines/, and every
baseline/graduation/gating path in run.ts (`--update`, `mutation:baseline`).
run.ts now exits non-zero only on a harness failure, never on a score. The
report renders the per-kernel table (kernel, score, killed, survived, total,
timeouts) plus the surviving mutants a strengthening PR works from.

Kernel scoping stays: stryker.config.json and KERNEL_MODULES are untouched.

* fix(mutation): restore denominator coverage and publish the table before judging the shard set

Review of #1828:
- `report.test.ts` re-asserts that Ignored/CompileError/RuntimeError leave the
  denominator — the one behaviour `ratchet.test.ts` covered and nothing replaced.
  A `tally()` edit that counted tool noise would have deflated every published
  score with a green `mutation:test`.
- `assertShardsCoverModules` now runs after `emit()`, so an incomplete shard set
  still publishes the kernels that completed instead of only an error string.
  This makes the workflow comments' claim about the job summary true rather than
  re-wording them down.

* chore(mutation): trigger the affected lane on exactly the paths that can select mutants

The PR lane returns an empty matrix unless the diff touches the harness, so the
kernel-source and `**/*.test.ts` triggers only bought a 1-4 min no-op job on
~96% of PRs. `on.pull_request.paths` is now exactly `LANE_TOOLING` plus the
workflow file, asserted in both directions by workflow.test.ts against the
exported constant — a missing path would let a harness change merge unproven,
an extra one starts a job that can only answer `[]`.

Also drops the workflow header's contradictory scope paragraph: it claimed the
lane selects on kernel sources and any test reaching one, which has not been
true since the ratchet went.

* fix(mutation): score and publish a short shard set before failing on the count

The expected-count check ran inside readShardedReports, before anything was
summarized, so on the weekly's real `--expect-shards 10` one dead shard threw
away the nine that had reported — the earlier reorder only moved the
zero-mutants check. The merge now returns the shard count, and both verdicts
run after emit() with the same exit code and `score` stage.

Regression uses the weekly argument shape (`--expect-shards 10`, one shard
present) and asserts the reporting kernel's row reaches stdout while the run
still fails.
2026-08-18 17:47:29 +02:00
Michał Pierzchała d76e0f94e9 refactor: migrate snapshot to device runtime (#1779)
* refactor: migrate snapshot to device runtime

* refactor: complete snapshot runtime policy cutover

* test: enforce snapshot owner-facts admission

* refactor: consolidate desktop snapshot capture

* fix: scroll to visible iOS smoke targets

* fix: close snapshot cutover alias bypasses

* fix: constrain snapshot admission identity flow

* fix: enforce snapshot admission through owner facts

* fix: adapt replay source tests to snapshot runtime
2026-08-18 15:49:24 +02:00
Michał Pierzchała ef6ec2995b chore(layering): document R12/R18/R19, retire R8, make R9 shrink mandatory (#1781 A6) (#1825)
* chore(layering): document R12/R18/R19, retire R8, make R9 shrink mandatory (#1781 A6)

The A6 review kept `check:layering` in full (15/15 planted violations fired,
no other enforcer exists) and left four follow-throughs.

R12 bin-alias-fast-path, R18 contracts-implementation-authority and R19
selector-pipeline-ownership were live rules with no ADR or CONTEXT anchor —
they now carry one each, in the same list as R7/R9/R10/R13.

R8 zero-dep-job-closure is retired: no CI job sets `install-deps: false` and
ci.yml records why each keeps it enabled, so the invariant has no subjects.
R11's relative-into-packages exception existed only because a zero-dep closure
cannot coexist with specifier loads, so it retires with R8; the route is now
closed to every caller. R1 was retired the same way at #1490.

R9 was growth-only and merely suggested lowering the ceiling, which is
headroom the next change spends without a number moving. It is now an equality
pin like R6 and the R10 R7 counts, and the committed baseline drops 47 -> 46
(daemon-server ceiling 17 -> 16) to match the measurement.

ADR 0019 §6 now says each runtime-command-cutover row is deleted when that
command's migration is declared closed.

* chore(layering): rename R9 to type-cycle-size now that it fails both ways (#1781 A6)
2026-08-18 15:35:46 +02:00
Michał Pierzchała 4b44c1c53a chore(test): remove the contention retry and shrink the subprocess-stub project (#1781 A4) (#1827)
The enumerated single-retry policy (#1419) has fired zero times since it
landed on 2026-07-29: 0 of 234 sampled Coverage-job lane envelopes
(2026-08-11 to 2026-08-18) have retryCount > 0, and none of 17 recent
failed runs was retried (5 refused "outside the enumerated retry list",
4 refused "unhandled error"). All three trackers its entries pointed at
(#1098, #1414, #1419) are closed. It cost ~1,454 LOC, a per-run secret
marker threaded through a setup file on every Vitest project, and a
standing obligation for every future gate reporter to call the blocker
bus.

Delete the scripts, tests and fixtures, the check:contention-retry
script and gate, the envelope artifact upload, and the runner-timeout
setup file; test:coverage:ci is a plain `vitest run --coverage` again.
lane-envelope.ts stays: the mutation, fuzz and concurrency-torture lanes
build their envelopes from it. run-blocker-bus.ts goes: its only
consumer was the retry's failure sink, and its only publisher already
fails the run by setting process.exitCode.

Keep the subprocess-stub project for the three files that really spawn
(client-metro, fuzz harness, fuzz corpus-replay) and drop the three that
run in 31/212/277ms in CI, which cannot contend for anything. The list
is now a plain array in vitest.config.ts with the reason at each entry.
Membership and the project's kill criterion live in #1823.

Because test:coverage:ci is a bare vitest run, the gate manifest reads
its projects directly, so OPAQUE_RUNNERS no longer needs it and an
unrun Vitest project becomes unrepresentable rather than detected; the
audit test now constructs that state by project-scoping the script.
2026-08-18 15:35:25 +02:00
Michał Pierzchała 142d156338 ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7) (#1789)
* ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7)

* fix(ci): skip the runner server entry point in the nightly and validate both test flags

* docs(ci): restate the nightly lane cost and timeout honestly

* docs(ci): stop quoting XCTest counts that drift between commits

* ci(ios): tighten the nightly timeout to the measured suite duration
2026-08-18 12:00:43 +02:00
Michał Pierzchała 801734d433 feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path (#1804)
* feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path

Adds `createAgentDeviceTools()` under a new `agent-device/ai-sdk` subpath,
built from the same command registry the MCP server uses so both stay in
lockstep without a hand-maintained tool list. Introduces a `frameworkTier`
descriptor facet ('core' | 'extended') so the factory can default to a
curated perceive/act loop instead of handing a model dozens of tools.

`ai` is wired as an optional peer dependency, imported lazily inside the
factory rather than at module scope, so importing the subpath itself never
requires `ai` to be installed - only calling it does. The package's own
publishing gate (scripts/lib/shipped-imports.ts) is extended to recognize
peerDependencies as a valid resolution source, since this is the first
optional peer this package has shipped.

Also restructures the AI SDK doc around three tiers (zero-code via
@ai-sdk/mcp, the new typed tool set, hand-written tools) and fixes a stale
`needsApproval` reference in favor of the current `toolApproval` API.

* fix(layering): classify src/ai-sdk as a rank-4 zone

The layering guard requires every src/<folder>/ to be explicitly ranked or
unranked; the new src/ai-sdk/ subpath (added in the prior commit) was left
unclassified, failing CI's Layering Guard job. It sits at the same tier as
client/compat/daemon-server/metro/remote/sdk - a public integration surface
consuming mcp (3) and core (2), imported by nothing else in the tree.

* fix(ci): cover, exempt, and pack the new ai-sdk subpath

Fixes the remaining CI failures on the ai-sdk subpath commit:

- Coverage: src/ai-sdk/index.ts had no dedicated unit test (only manual/
  integration verification), so changed-line coverage sat at 6.9% against
  the 70% gate. Adds src/ai-sdk/__tests__/index.test.ts (core vs 'all' tool
  filtering, session/platform pinning and schema hiding, error
  normalization, toolApproval passthrough) with createCommandToolExecutor
  and createAgentDeviceClient mocked the same way command-tools.test.ts
  does, plus a dedicated missing-peer-dependency.test.ts that mocks `ai`
  itself to throw, isolated to its own file so it doesn't affect the other
  tests' use of the real, installed `ai` package. Changed-line coverage is
  now 29/29 (100%).
- Fallow Code Quality: src/ai-sdk/index.ts and examples/sdk/ai-sdk-tools.ts
  are entry points with no in-repo importer (reached only via package.json
  exports / run directly), and the new subpath's exports are unused
  internally by design - both need the same treatment src/sdk/*.ts and its
  examples already have in .fallowrc.json.
- Integration Tests: test/integration/installed-package-metro.test.ts and
  src/__tests__/package-exports.test.ts each hand-list every published
  subpath and smoke-check it from a real packed install; added ./ai-sdk to
  both so the new subpath is actually exercised, not just silently passing.

* fix(ai-sdk): hide MCP transport/config fields from the model too

createAgentDeviceTools() only removed session and mcpOutputFormat from tool
schemas. stateDir was still model-visible and reached the shared executor
as client configuration, letting a tool call redirect into a different
daemon state directory - defeating the "one pinned session" guarantee the
factory exists to provide. includeCost and responseLevel are MCP
tool-config knobs in the same category, irrelevant to this adapter.

Widens the hidden-field set to session/stateDir/mcpOutputFormat/
includeCost/responseLevel, and now strips them from the runtime input
inside execute() too (not just the schema), so the guarantee holds even if
a caller bypasses schema validation. The schema-properties filter and the
input filter now share one omitHidden() helper instead of two near-
duplicate implementations.

Addresses the P1 review comment on #1804.
2026-08-18 11:57:34 +02:00
Michał Pierzchała 04613ae8d3 ci: keep Bundle Size job green on transient GitHub comment failures (#1795)
* ci: keep Bundle Size job green on transient GitHub comment failures

The size measurement and job summary had already succeeded on PR #1789
(run 32050847506) when the PR comment write got a 503 during a GitHub
incident and failed the whole lane.

--post-comment now retries 5xx / 429 / network errors (4 attempts,
1s/2s/4s backoff) on both the list and write calls. If it still fails,
it prints a ::warning::, appends a note to $GITHUB_STEP_SUMMARY, and
exits 0. Other 4xx (bad token, missing permissions) stay fatal.

* refactor: split GitHub response classification to satisfy fallow complexity gate

* fix: reconcile uncertain comment creates instead of re-POSTing; add regressions

Retry now wraps the whole list -> write cycle rather than each request, so a
create whose response was lost (network error / 5xx) is re-listed on the next
attempt and turned into a PATCH of the marker comment instead of a duplicate
POST. Splits the retry/classify helpers under the fallow complexity gate.

Adds scripts/__tests__/size-report-post-comment.test.ts (unit-core): spawns the
real script against a stubbed fetch and pins uncertain-create reconciliation,
transient exhaustion (warn + exit 0), and fatal 4xx (nonzero, no retry).
SIZE_REPORT_RETRY_BASE_MS lets the tests skip real backoff.
2026-08-18 11:35:36 +02:00
Michał Pierzchała ccf64f6797 ci: move parked device replay suites to a dispatch-only workflow (#1781 A1) (#1794)
* ci: move parked device replay suites to a dispatch-only workflow (#1781 A1)

Both full-tier device jobs have failed every scheduled run since 2026-07-24: the
Android suite inside full-tier scenarios that had never executed end to end, the
iOS suite on varying steps. They move to .github/workflows/replays-manual.yml,
which has no `schedule:`, so the schedule stops emitting a guaranteed failure while
the suites stay runnable on demand.

A job-level `if: github.event_name == 'workflow_dispatch'` would have looked the
same and lied: `workflowLanes()` decides `qualifying` per workflow FILE and never
reads job-level `if:`, so the manifest kept reporting replay-android, replay-ios,
and replay-ios-device as scheduled-lane owners — the silent-owner-loss failure the
manifest exists to catch. A separate file is what the file-level model already
reads correctly.

Those three checks now have no pull_request/schedule owner, so they are declared as
MANUAL_ONLY_OWNERS rather than folded into UNPROVABLE_OWNERS, whose claim ("it runs,
this loader cannot see it") is no longer true for replay-android. check:gate-manifest
drops from 48 to 46 wired checks and names the three on every run. Two tests pin it:
a dispatch-only lane is non-qualifying however many gates it declares, and every
manual-only declaration must name a registered check that no qualifying lane owns, so
a re-scheduled lane cannot keep a stale exemption.

* ci: attest manual-only checks against their dispatch lane (#1781 A1)

Review P1: MANUAL_ONLY_OWNERS was a negative allowlist — it proved each entry named a
registered check no qualifying lane owned, but nothing tied the entry to a lane that can
still run it. Deleting a parked job, or its run-gate step, would have left the manifest
green and still printing the check as manual-only: parked coverage silently becoming
deleted coverage.

Each entry now names its dispatch lane, and a new 'manual-only' audit assertion resolves
that name against the derived model: the lane must exist, must still be dispatch-only, and
must still declare the gate. replay-android carries an explicit `opaque` flag because its
gate sits inside the third-party emulator action's `script:` (#1429), so the job's
existence is the whole attestation the model can make — and the flag says so rather than
letting an unreadable lane look like a declaring one.

Four regressions pin both directions: deleting a declaration reports the check as unowned;
deleting the parked job fails with 'no workflow defines'; re-scheduling the lane fails
until the entry is dropped; and a parked lane that loses its run-gate step fails unless the
entry is opaque.

* ci: make manual-only mean dispatch-only, not merely non-qualifying (#1781 A1)

Review follow-up: the attestation checked `qualifying === false`, which is true of any lane
that is not pull_request/schedule. Swapping `workflow_dispatch` for `push` in
replays-manual.yml would have kept the audit green and the checks printed as manual-only,
while the runs nobody starts by hand quietly started themselves on every push.

The lane model now keeps the trigger names instead of collapsing them into that one bit, and
the manual-only assertion requires `workflow_dispatch` and nothing else. Three planted
regressions cover the gap the review named: a parked lane re-triggered by `push` fails, a
parked lane with no trigger at all fails, and the loader test pins that trigger kinds survive
into the model (a push lane reads `[push]`, the nightly reads `[schedule, workflow_dispatch]`).
2026-08-18 09:59:34 +02:00
Michał Pierzchała d8a7d03faf refactor: route application lifecycle through runtime facts (#1759)
* refactor: route application lifecycle through runtime facts

Moves the canonical `open`, `prepare`, `close` and internal `runtime` descriptors
behind package-owned lifecycle bindings admitted from device runtime facts, while
daemon request/session policy and public response construction stay put.

Based on main, which already carries the boot unit, the parametrized cutover gate
and the apps unit. Readiness is package-owned there, so the Apple and Android
bindings call ensureAppleReady/ensureAndroidReady rather than a root readiness
bag; ensureAppleReady gained an onColdBootStart hook so open keeps warming the
runner cache in parallel with a cold boot, and a narrow markBooted port publishes
readiness' fresh observation so a flow still makes one simctl listing.

Cutover rows take R24-R27, clear of the accepted catalog and the sibling install
stack, and cutoverTableDefects rejects a duplicate rule id.

Two defects this unit introduced are fixed here rather than shipped:
`open <app> <url>` dropped the URL on a first open, and test-IME activation was
first fatal on an unobtainable helper and then over-caught. Helper unavailability
is a typed non-activation outcome now; fence, lock and post-record failures
propagate.

The duplication the unit had accumulated is gone: one runtime-admission module
instead of five per-command copies, one direct-lifecycle binding factory instead
of six hand-rolled packages, one transport-hint predicate, one session
finalization path, and no identity-wrapper module.

* fix: allocate lifecycle cutover rows after deployment

* chore: preserve lifecycle union reconstruction

* fix: reconcile lifecycle runtime stack

* refactor: tighten lifecycle runtime topology

* refactor: remove superseded runtime adapters

* fix: preserve stacked runtime cutovers

* test: preserve migrated runtime ownership

* test: move Android deployment retry ownership

* test: extract runtime hint fixtures

* fix: preserve lifecycle stack invariants

* fix: complete lifecycle runtime cutover

* fix: remove lifecycle cutover residue
2026-08-16 15:13:10 +02:00
Michał Pierzchała 66cca1a5b8 refactor: route install commands through platform runtime (#1758)
* refactor: route install commands through platform runtime

* fix: preserve stacked runtime facts

* fix: align deployment facts with shutdown runtime

* refactor: simplify capability facts projection

* style: format capability facts projection

* fix: preserve migrated capability ownership

* fix: propagate deployment artifact cancellation

* refactor: move Harmony deployment mechanics into package

* refactor: move Apple deployment tools into package

* refactor: move Android deployment tools into package

* refactor: inject deployment temporary storage

* refactor: remove superseded deployment helpers

* fix: preserve provider deployment transport
2026-08-16 15:13:09 +02:00
Michał Pierzchała 5855dfc2e0 refactor: route shutdown through device runtime (#1757)
* refactor: route shutdown through device runtime

* fix: cover shutdown cutover review gaps

* fix: propagate shutdown cancellation

* fix: move shutdown mechanics to platform owners

* fix: pass device to shutdown fact fixture

* test: cover shutdown facts in session state fixtures

* test: simplify Android shutdown assertions

* fix: preserve Apple shutdown cancellation
2026-08-16 15:13:08 +02:00
Michał Pierzchała 39cd4d346a refactor: route appstate through platform runtime (#1755)
* refactor: route appstate through platform runtime

* test: keep appstate capability fixture below complexity limit

* test: cover appstate required readiness fact

* fix: align appstate facts with boot readiness

* fix: keep appstate use declaration minimal

* fix: close appstate parity and ownership gaps

* docs: record final appstate size accounting

* fix: merge neutral runtime imports

* docs: align final appstate size totals

* fix: remove stale runtime dependency edges

* docs: correct appstate size accounting

* refactor: keep runtime-use factory internal

* docs: itemize runtime-use relocation

* fix: move appstate queries into runtime packages

* refactor: retire root foreground query paths

* refactor: share Android foreground parser ownership

* fix: preserve Android appstate parser precedence

* docs: keep appstate evidence in review artifacts

* fix: keep appstate runtime loading lazy

* fix: fail closed for stale limrun appstate

* fix: preserve limrun recovery and abort appstate

* fix: narrow limrun exact-owner recovery

* fix: allocate appstate cutover rule

* fix: reconcile appstate with merged main

* style: format harmony runtime test

* fix: allocate appstate rule id

* fix: allocate appstate layering rule

* fix: remove stale app command admissions

* fix: close appstate layering regressions

* fix: align Harmony capability parity with runtime facts

* test: cover limrun recovery-only readiness

* fix: keep Limrun recovery binding app-log only

* fix: parse Android app state in linear time
2026-08-12 16:55:50 +02:00
Michał Pierzchała 9c22467832 refactor(ci): make gate ownership structural (#1429) (#1753)
* test(ci): prove every registered gate is owned and reachable (#1429)

A check that silently stops running looks exactly like a green build. Two
suites had already stopped: `check:tmpdir-leaks` (with its model tests) and
`test:fixture-cache` are real package scripts that no workflow ran, reachable
only through the `check:unit` aggregate CI never invokes.

`CHECK_CATALOG` becomes the registry of every check and `pnpm gate <id>` the
only way CI runs one, so finding what a lane runs is a scan for `pnpm gate`
rather than an attempt to interpret shell. `pnpm check:gate-manifest` then
asserts against the real workflows that every registered check is run by some
qualifying lane (per unit, not per script name), that every check the real
selector activates for a path is run by a lane that path would start (#1420's
class), and that every Vitest project and suite script belongs to a check.

The wiring that keeps those honest is asserted too: a gate id must name a
registered check, an `if:` must be ruled on in GATE_CONDITIONS so `if: false`
unowns what it guards, an action declared to run a gate is proven to, and a
job whose steps the loader cannot open fails closed.

It deliberately does not try to prove CI runs project code only through
`pnpm gate`. Whether a shell block executes project code is not decidable from
its text, so shell this model does not recognise earns no ownership credit —
the failure direction is a check reported unowned, never one waved through.

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

* test(ci): update the two suites that assert on rewired workflow text

`scripts/mutation/workflow.test.ts` and `test/ci/trusted-fixture-artifact.test.mjs`
read the workflow and action files and assert on their command text, so routing
those steps through `pnpm gate <id>` moved what they were matching.

They are the two suites the manifest cannot help with: it proves a gate is still
run, not that a test asserting on how CI spells a command was updated with it.

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

* fix(ci): credit gates by execution shape, and keep every guard

Three ways the manifest could report a gate as owned when it does not run.

1. Crediting was a substring scan over `run:`, which #1429 explicitly rules
   out — "do not infer reachability from a command name merely appearing in
   workflow text". `false && pnpm gate x`, a gate inside `if false; then … fi`,
   one named in a heredoc, and `echo pnpm gate x` all credited it. There is a
   live instance: conformance-regenerate.yml's "Fail if regeneration changed
   anything" step names `pnpm gate maestro-regenerate` inside an error message
   telling a human to run it, and that credited the gate.

   A gate now counts only as the first command segment of a line, and a body
   carrying shell structure earns nothing. Reachability inside a script is not
   decidable, so this does not try: unrecognised shape means no credit and the
   check reports unowned. `VAR=$(pnpm gate x …)` is read, since the assignment
   form is unambiguous and the gate runs.

2. Job-level `if:` was not modelled at all, though six live jobs carry one, so
   a job that cannot run still credited every gate inside it. Two conditions on
   the mutation lanes are now declared.

3. A caller's `if:` REPLACED the guard on a nested composite-action step
   (`guard[0] ?? step.condition`), so an outer `always()` erased an inner
   `if: false`. Steps carry every guard between the lane and the step.

Also corrects two source comments that still claimed project code run outside
the runner fails the manifest. It does not: such a step earns no credit.

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

* ci: add the run-gate action that names a gate structurally

The seam the ownership proof will read instead of shell. A lane says which
gate it runs in `with.gate`, a typed input the manifest reads straight out of
the YAML and validates against CHECK_CATALOG.

Nothing here is wired yet — the ~60 call sites and the model change follow.
Added first so the target of that conversion is reviewable on its own.

`args` cannot select which gate runs; it is appended after the id, so the
worst a wrong value does is fail the gate it already named. There is no
`|| true` and no output capture: the gate's exit code is the step's exit code,
so a gate cannot run without being able to fail its lane.

Part of #1429.

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

* merge: main (#1770) and route its three new steps through the runner

#1770 landed the orphan-check fix on main, wiring `check:tmpdir-leaks`,
`check:tmpdir-leaks:test` and `test:fixture-cache` into Coverage, Layering
Guard and Integration Tests. This branch had wired the same three through
`pnpm gate`, so the merge produced two steps per check rather than a conflict
— each check ran twice.

Kept main's steps, with the placement and reasoning reviewed on #1770, and
changed only their `run:` line to the canonical runner. Dropped this branch's
duplicates. Net effect on CI is unchanged: the same three checks, in the same
three lanes, once each.

Gate manifest green after the merge: 47 checks wired across 33 lanes.

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

* fix(ci): address review — suite detection, freerange, glob, vacuous skip-list

Six review findings plus the mutation blocker.

[bug] `registered` was shape-only, so a `test:*` script running
`node src/bin.ts test <dir>` resolved to a `script:` leaf and was invisible.
Four `test:replay:*` scripts were owned only because someone hand-registered
them; `test:replay:android` was neither registered nor reported while the
nightly ran the same six .ad files by inlining them. A `test:*` script is now
a suite by name. `replay-android` is registered, and the nightly runs the
script instead of re-listing its files so the two cannot drift.

  The nightly invokes it inside `reactivecircus/android-emulator-runner`'s
  `script:` input — shell handed to a third-party action this loader does not
  read — so the suite executes but cannot be credited. Recorded in
  UNPROVABLE_OWNERS with that exact reason rather than assumed.

  The fixed detector also found a second orphan the review did not name:
  `test:integration:progress`. That one is a reporter whose `--check` sibling
  is the registered gate, so it is declared in REPORTING_SCRIPTS — a
  declaration that itself fails when inert.

[bug] `freerange` defaulted to localRunnable, so fail-open ran `fr` (a Bun
binary) on the pre-push path. Now false.

[suggestion] The `--run` skip-list asserted `build:android-snapshot-helper`,
a name `android-helpers` no longer uses, so it could not fail. Derived from
the catalog instead.

[suggestion] `matchesGlob` joined `**` splits with `.*`, making the adjacent
slash mandatory — GitHub's `**` matches zero directories, so
`src/**/*.test.ts` did not match `src/a.test.ts`. Pinned against
`packages/*/src/**/*.test.ts`.

[suggestion] Deleted the unwired `run-gate` action. It had no callers, was
absent from GATE_ACTIONS, and its comment described a system that had not
shipped. It returns with the rewiring, not before.

[suggestion] Collapsed the module headers that narrated discarded designs.

Mutation: `daemon entrypoint publishes HTTP metadata and cleans up on
shutdown` is the only test here that spawns a real daemon process. It takes
~1.1s alone but exceeds Vitest's 5s default inside Stryker's dry run, which
aborts the sweep before a single mutant runs. Given 30s.

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

* fix(mutation): order sandbox aliases longest-first so subpaths resolve

Every shard of the mutation sweep aborted in Stryker's dry run with:

  Cannot find package '@agent-device/selectors/engine' imported from
    .tmp/stryker/sandbox-*/src/core/selector-pipeline.ts

The alias was generated correctly; it just never won. Vite matches a STRING
alias by prefix and takes the first hit, and `workspaceSpecifierTargets`
emitted the bare `@agent-device/selectors` ahead of the subpath entries. The
bare entry therefore captured `@agent-device/selectors/engine` and rewrote it
to `…/src/index.ts/engine`, which does not exist; Node fell back to real
package resolution, could not find the subpath inside the sandbox, and the dry
run failed before a single mutant ran — so the shard uploaded an empty
envelope instead of a report and the ratchet failed for want of one.

Sorting longest specifier first makes the most specific alias win:

  @agent-device/selectors/engine -> packages/selectors/src/engine.ts
  @agent-device/selectors/ast    -> packages/selectors/src/ast.ts
  @agent-device/selectors        -> packages/selectors/src/index.ts

`/ast` never tripped this because nothing in a related test set imported it;
`selector-pipeline.ts` introduced the first subpath import that mattered
(#1744), so the mutation lane has been unable to run since that landed. Any
PR touching `scripts/mutation/**` — which fails open into the full sweep —
would have hit it.

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

* refactor: derive gate ownership from workflow structure

* fix: run gates without optional arguments

* fix: resolve mutation workspace subpaths exactly

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 16:02:18 +02:00
Michał Pierzchała c7565cb1f8 refactor(snapshot): clean snapshot ownership (#1754)
* refactor(snapshot): clean snapshot ownership

* fix(snapshot): address ownership review feedback
2026-08-12 13:44:21 +02:00
Michał Pierzchała eabc936a0f refactor: route apps through request runtime (#1756)
* refactor: route apps through request runtime

* test: remove stale apps adapter mock

* fix: clean apps runtime replay artifacts

* fix: remove stale runtime test exports

* refactor: simplify apps runtime admission

* test: exercise runtime use through facade

* fix: close apps runtime admission gaps

* fix: disambiguate doctor app inventory callback

* fix: keep capability fixture below complexity limit

* fix: keep HarmonyOS app inventory fail-closed

* test: type HarmonyOS app admission fixture

* fix: restore HarmonyOS app inventory parity

* test: align HarmonyOS readiness fixture

* fix: preserve HarmonyOS doctor app parity

* test: type HarmonyOS doctor fixture

* fix: isolate HarmonyOS doctor policy

* fix: align apps cutover with shared rule catalog
2026-08-12 11:55:09 +02:00
Michał Pierzchała b13c06338e refactor: route boot through readiness runtime (#1747)
* refactor: route boot through readiness runtime

* fix: separate boot admission from readiness

* fix: register boot cutover policy

* refactor(runtime): move readiness into platform owners

* fix(test): tolerate provider temp cleanup races
2026-08-12 11:55:09 +02:00
Michał Pierzchała ba48146520 refactor(layering): one parametrized runtime-command-cutover gate (ADR 0019 §8) (#1745)
* refactor(layering): fold the four cutover policies into one parametrized gate

ADR 0019 §8: the per-command cutover gates consolidate into one parametrized
runtime-command-cutover gate driven by a table of migrated commands. Adding a
migrated command adds a row; the mechanism carries one planted-red proof instead
of one per command.

Part of #1739 (wave 0)

* fix(layering): scope cutover calls to lexical owners

* chore: format cutover ownership model
2026-08-12 11:55:08 +02:00
Michał Pierzchała 8f98d23f14 refactor(layering): give each colliding rule id its own number (#1750)
* refactor(layering): give each colliding rule id its own number

R11 and R13 each named two unrelated rules. report() groups violations by the
rule string and titles every annotation `Layering drift (${rule})`, so a shared
number made the guard's output ambiguous about which rule fired.

Reference counts decided which rule keeps its number. R11 package-boundaries is
named in ~30 places (CONTEXT.md, ADR 0019, testing.md, the mutation and
affected-check configs, four package source comments, its own tests) against one
for the contracts rule; R13 platform-package-substrate is the RULE in three
policy files plus CONTEXT.md, ADR 0019 and model.ts against two for the devices
cutover. Both keepers stay put and the two newest rules move up:

  R11 contracts-implementation-authority -> R18
  R13 device-inventory-cutover           -> R17

R17/R18 follow the namespace's order-of-addition convention (R14 #1701 < R15
#1702 < R16 #1724): device-inventory-cutover landed in #1699 and
contracts-implementation-authority in #1701. #1656 took R19 for
selector-pipeline-ownership on the same reading.

The rule-map header in check.ts is renumbered and reordered back into numeric
order, and gains the R18 entry the contracts rule never had -- without it a
reader looking up an R18 violation finds nothing where they used to find the
wrong rule. deviceInventoryCutoverSummary() was also the only OK-line summary
not leading with its rule number, which is what made the number unreadable from
the success line in the first place.

Also corrects a normative ADR reference. ADR 0019's platform-package import
rules -- contracts-to-platform, sibling-platform, root/daemon, raw-process --
are R13's, as CONTEXT.md:420 already says. The R11 attribution predates
platform-package-policy (#1697, a day before #1699), when R11 was the only
package rule.

* chore(layering): retire the expired R11/R13 collision allowances

KNOWN_RULE_ID_COLLISIONS was opened for exactly the two collisions the previous
commit renames apart, and ruleIdCollisionFailures expires an allowance on
contact: once the collision is gone the entry fails as stale, because a list
still naming it would wave it back through if anyone reintroduced it.

Both entries are therefore deleted in the change that removes the collisions,
leaving the empty list that admits nothing. The namespace is now one-to-one
across R2-R19.
2026-08-12 11:42:15 +02:00
Michał Pierzchała 7f5dbd2e50 chore: drive unused production exports to zero (#1743)
* chore: drive unused production exports to zero

`pnpm check:production-exports` has been failing on main with 21 findings.
Each was investigated rather than blanket-suppressed; they split three ways.

Genuinely dead, deleted:
- `androidDeviceForSerial` (android/adb.ts) had zero references anywhere,
  tests included.
- `streamAndroidLogcatWithAdb` (android/logcat.ts) had no production consumer
  and only a guard-clause test; its `captureAndroidLogcatWithAdb` sibling is
  the published SDK surface. Removed with its options type and test.

Test-only aliases over live siblings, collapsed:
- app-log-resource-store re-exposed four bound store methods; production used
  only `resolvePath`, tests used the other three. The sibling
  screen-recording-resource-store exports just the store, so this now matches:
  one export, all consumers call `appLogResourceStore.x`.
- device-claims re-exported `canonicalLocalDeviceKey` for a single test, while
  production imports it from device-claim-paths directly. Dropped the
  re-export and pointed the test at the canonical module.

Real consumers the analysis cannot see, exempted with the reason:
- The nine remaining `src/cli/commands/*Command` handlers are reached only
  through `dedicatedCliCommandHandlerLoaders`, the dynamic import() table in
  router.ts. `deviceCommand` already carried an inline suppression for exactly
  this; replaced it with one config entry naming the table that enumerates all
  ten, matching the existing daemon route-handler entry.
- `resolveVitestMaxWorkers` (vitest.config.ts), `DEVICE_CLAIM_IN_USE_SAMPLE`
  (bench sample producers) and the capture-kit `createAppLogLiveHandle` facade
  export joined the existing entries that already record their exact shape.
- `**/*.fixtures.ts` is now an ignorePattern: all 15 build doubles for
  co-located tests, several import `vi`, and none is imported by production
  source. Pattern-matching them as test infrastructure also keeps this class
  of finding from recurring.

Gate now reports zero. Unit suite, layering, fallow audit, MCP metadata,
build, bundle-owner and package checks all pass.

* chore: scope the fixture exemption to unused exports

Review feedback on #1743: `ignorePatterns` removes a file from every Fallow
mode and rule, but the false positive here is only production-unused-exports.
Moved *.fixtures.ts to an ignoreExports entry so fixtures stay inside health,
dead-code and cycle analysis. Kept `exports: ["*"]` rather than today's three
symbols because the property is per-file — no fixture module has a production
consumer — so a new fixture symbol should not reopen the finding.

check:production-exports still reports zero, and a full `fallow --summary`
returns identical totals (2 dead-code / 4 dupes / 123 health) with and without
the change, so nothing was newly surfaced or newly hidden.

* test(fallow): prove fixture policy scope
2026-08-12 11:17:33 +02:00
Michał Pierzchała 74eab2a554 refactor: route selector-resolution structural stages into typed policy (#1744)
* refactor: route selector structural stages into typed policy

#1649 landed the per-caller ambiguity matrix and deliberately left four
structural columns out: occlusion, off-screen, hittable-ancestor promotion,
and the poll budget were per-caller pipeline code, so declaring them would
have been an unverifiable claim (nothing consumed them; flipping one left the
suite green).

This adds the missing half as a table with runners. `SELECTOR_PIPELINE_POLICIES`
(src/core/selector-pipeline-policy.ts) gives each caller ONE row naming its
ambiguity contract plus its four stages, and every stage is reached only
through a runner that reads the row:

- occlusion -> selectorPipelineCandidates (candidacy) and
  resolveSelectorPipelineTarget (refusal). Acting rows exclude covered nodes
  and refuse covered targets; `find` and the diagnosis probe keep them as
  candidates and refuse at the target; reads and `wait` ignore them.
- promotion -> resolveSelectorPipelineTarget. The per-call-site
  `promoteToHittableAncestor: boolean` is gone: click/press/longpress name
  `promotedTarget`, fill/focus/scroll/drag endpoints and the native-ref
  preflight name `resolvedTarget`. `find`'s below-the-root variant is a
  declared value rather than a second local helper.
- off-screen -> throwIfOffscreenInteractionTarget, which now takes the row and
  returns the node untouched (no iOS rescue round trip) for observation rows.
- poll -> selectorPollBudget, which createWaitPolling derives its deadline and
  inter-poll delay from; the two wait loops carry a budget, every other row
  carries none and cannot be polled.

Behavior is byte-identical. The acting refusal keeps its exact node, label and
details in every branch (promotion declines to retarget away from a covered
node, so the "both covered" case names the same node it always did), and
`find` carries the occlusion verdict to the focus/type seam rather than
raising it early, because find click/fill still delegate that refusal to the
interaction leaf's own error shape.

selector-pipeline-policy.test.ts drives EVERY row through EVERY runner,
including the rows whose answer is "skip" — the half that used to be an
absence of code, and an absence cannot fail. Each stage was proven red by
flipping its cell (occlusion, promotion, off-screen, poll, plus the
declare-only-what-is-enforced guard). The ADR 0011 occlusion/nonHittable
`via` pointers for the runtime tree paths now name the runner that makes the
decision, not the predicate it applies.

Closes #1656; prework for #1739 (waves 4-5).

* docs: state constraints instead of narrating the refactor

Comment pass over #1656: drop the "used to be per-caller code" /
"not module constants" / "rather than an omission" narration — a comment
should say what a future edit must respect, not what the previous shape was —
and compress the find occlusion-verdict and poll-budget notes to the
constraint they actually carry.

* refactor: make the selector pipeline the only door to the engine

Review of #1744: the structural rows were declared but bypassable. Read and
wait routes composed `selectorPipelineCandidates(row, nodes)` with the raw
`resolveSelectorChainWithPolicy(..., row.resolution)` and never entered the
promotion or off-screen stages, so flipping a read row's `promotion` or
`offscreen` changed only the policy unit tests — production `get`/`is`/`wait`
were unaffected, which is the unverifiable-column failure #1656 exists to
remove. Callers could also pair one row's candidate set with another row's
ambiguity contract, and `find list` reached the engine directly.

The owning interface (src/core/selector-pipeline.ts) now runs every stage a row
declares, skips included, and the stage functions are private to it:

- `resolveSelectorPipeline` — single-target rows: candidacy, ambiguity, the
  replay-guard hook, promotion, occlusion, off-screen.
- `listSelectorPipelineMatches` — `reject-candidates` rows, returning the
  candidate set AND the tree the row sees, so ranking and equivalence
  classification judge the same nodes candidacy produced.
- `runNodePipelineStages` — the node stages for a target from a non-chain
  matcher (`@ref`, find's fuzzy locator) or a narrowed candidate set.

A row whose off-screen stage refuses must supply a refusal shape, so flipping
an observation row to `refuse` fails on its real route instead of silently
observing. `find list` now names a `readList` row (the new
`reject-candidates`/no-rect ambiguity row) instead of calling the engine.

R17 selector-pipeline-ownership (scripts/layering/) makes the bypass
structurally inexpressible: only the owner may import the engine entry points.
Proven against a planted import in selector-read.ts, which the repo-wide scan
rejects with the entry points that replace it.

Flips now fail through REAL command routes, verified one at a time:
readUnique.occlusion/offscreen/promotion and wait.occlusion via get attrs / is
/ wait; readAny.offscreen via is exists and find; readList.occlusion via find
list; promotedTarget.promotion via runtime click. The wait route test needed an
advancing clock first — with the frozen one a refused wait spun instead of
failing, so the flip hung rather than asserting.

* refactor: drop find's dead candidate binding

The selector branch bound the row's candidate set and never read it: only the
acting classification needs that tree, and find's locator branch brings its own
matcher. Names what actually governs the locator target — the shared node
stages below, not a candidate set it never had.

* refactor: reserve the selector engine behind the pipeline owner

Review of #1744 (three blockers).

**Listing rows no longer claim stages they cannot run.** `find <q> list`
resolves to a candidate SET, so promotion, the off-screen guard and a poll
budget have nothing to apply to — a listing has no single element to retarget,
keep on screen, or wait for. `readList` now declares only the two stages a
listing executes (`SelectorListPolicy`: resolution + occlusion), and the
narrower shape is load-bearing: `runNodePipelineStages` and `selectorPollBudget`
take the full row, so handing them a listing row is a compile error rather than
a silently skipped stage. Pinned with `@ts-expect-error` — widening `readList`
makes the directives unused and fails the typecheck.

**The engine door is a specifier, not a symbol.** R17's regex could not see a
namespace import, a re-export, or a deferred `import()`, none of which mention
the symbol it matched. The two engine entries moved to
`@agent-device/selectors/engine`, and R19 enforces over the resolved import
graph, where every one of those forms is the same edge. Proven on the
repo-wide scan by planting each form into a shipped route: namespace import,
dynamic import, and `export *` laundering all come back red.

`resolveImportEdges` drops an edge whose specifier resolves to nothing, so a
specifier rule goes quiet — not red — if the subpath is ever retired. The gate
now says that out loud instead of scanning clean.

**R19, not R17.** #1750 allocates R17/R18. Verified free against origin/main
and that PR's diff, then validated by real merges in both directions: the
uniqueness gate passes either way and the three ids stay distinct.

The gate itself is new (`scripts/layering/rule-ids.ts`): two branches taking one
free number do not conflict in git, so nothing caught R17 twice. Matching whole
string literals is what separates a declaration from prose that names a rule,
and it is what let the gate see #1750's `const RULE = '…'` shape — the first
version missed it and would have been vacuous. `main`'s two pre-existing
collisions (R11, R13) are listed as known, not pinned by equality, so #1750
lands in either order without breaking this.

Also: the root façade now exposes no resolver at all, and its surface test
pins both doors.

* fix(layering): make each rule-id allowance expire with its collision

Review of #1744: `KNOWN_RULE_ID_COLLISIONS` filtered the exact R11/R13
collision strings, so once #1750 renames those rules apart the entries would
keep waving those very collisions through if anyone reintroduced them. "Inert"
was wrong — a stale allowance fails open, permanently.

`ruleIdCollisionFailures` now checks the transition from both sides: a
collision nobody allowed fails, AND an allowance whose collision is absent
from the scan fails as a stale allowance. The entry therefore has to be deleted
in the same change that removes the collision, and the list burns down to
empty, which admits nothing.

#1750 is still open, so the transitional entries stay for now (option (b)).
Verified against a scratch tree carrying that PR's rename: leaving the list
untouched reports both entries as stale; deleting them is clean; and
reintroducing `R11 names contracts-implementation-authority and
package-boundaries` afterwards is rejected. The last of those is also a unit
regression, so the post-transition guarantee is pinned rather than argued.
2026-08-12 07:57:08 +02:00
Michał Pierzchała 057ab1c82d fix(layering): stop double-reporting contracts-authority violations (#1746)
* fix(layering): stop double-reporting contracts-authority violations

main()'s violation list spread checkContractsImplementationAuthority(sources)
twice, so every R11 contracts-implementation-authority finding was printed and
::error-annotated twice on a red run — inflating the headline violation count
and producing duplicate GitHub annotations on the same file:line.

Verified by planting a `setTimeout` call in a contracts production source: the
rule reported 2 identical violations before and 1 after, with the extra
annotation gone. `pnpm check:layering` stays green (136/136 policy tests).

Nothing in the suite covers main()'s assembly of the violation list — the
policy tests all call their rule functions directly — so neither a duplicated
nor a dropped entry there is currently detectable.

* test(layering): hold main() to wiring every rule exactly once

The duplicate this branch removed survived because nothing enumerates the
guard's rules: main()'s violation list is hand-written, and the per-policy
tests call their rule functions directly, never seeing the wiring. A lost
spread is the dangerous version of the same gap — the rule stops being
enforced and the run still prints OK.

Make the file's own bindings the oracle: every in-scope `check*` value, local
or imported, must be spread into main()'s violation list exactly once. That
covers both directions plus a third case — a policy written and never wired
in. Fails closed if main() or the array is renamed, so the instrument cannot
pass by finding nothing.

Test-only rather than an R17 inside the guard: a self-referential rule is
defeated by dropping its own spread, which is exactly the failure it exists
to catch.

Verified by mutating the real check.ts in both directions (re-planting the
duplicate, then dropping checkZeroDepJobs) — each turns the run red, and the
restored file is green at 143/143.

* test(layering): discover layering suites by glob instead of by hand

check:layering named its 14 test files one by one, so adding a policy test
meant remembering to register it — and twice nobody did. Both halves of the
R16 record cutover shipped with tests that have never run:

  scripts/layering/record-runtime-mechanics-policy.test.ts  (2 tests)
  scripts/layering/record-runtime-registry-policy.test.ts   (1 test)

Their policies are live in the guard; only the tests were dormant. All three
pass, so nothing had rotted — the coverage was simply never being collected.

Glob the directory the way mutation:test already globs its own, which makes
the filesystem the enumeration and retires the registration step. 143 -> 146
tests, still green.

This is the same defect as the duplicate spread this branch opened with, one
level up: a hand-maintained list with nothing checking it against reality.

* refactor(layering): register guard rules in a keyed table

Replaces the AST wiring guard with a construction that cannot express the
defect, per review on #1746.

The parser was the wrong instrument: it reconstructed one array's shape from
TypeScript syntax, so it only recognised top-level function declarations and
imports whose local name matched /^check[A-Z]/. A const-defined or aliased
rule was invisible to it, a helper named checkX was a false positive, and
naming and syntax became part of the interface — all to detect a mistake
rather than prevent it.

Rules now live in a keyed table over a shared context, executed once via
Object.values. An object cannot hold a key twice, so double registration is
unrepresentable rather than merely detected, and oxlint's no-dupe-keys
rejects the attempt at the source. LayeringRuleId makes a missing key a type
error, and LAYERING_RULE_IDS gives the catalog to check exhaustiveness
against. Call sites and order are unchanged, so grouped output and the
success line are byte-identical.

One regression test remains, through the production interface: scripts/ is
outside tsconfig.json's `include`, so the Record's exhaustiveness is an
editor signal rather than a CI gate, and the catalog assertion is what fails
the build when wiring goes missing.

Verified by mutation: dropping an entry and registering an uncatalogued one
both fail the test, a duplicated key fails oxlint, and re-planting the
original contracts violation reports it exactly once. Net -133 LOC.
2026-08-11 20:21:56 +02:00
Michał Pierzchała f5d9789764 feat: enforce local device claims and reconcile stale owners (#1735)
* feat: enforce local device claims

* fix: address device claim review feedback

* fix: persist canonical daemon claim state directory
2026-08-11 16:18:45 +02:00
Michał Pierzchała b8dd6a5854 refactor: tighten capture ownership boundaries (#1736) 2026-08-11 13:55:28 +02:00
Michał Pierzchała b0d4b40467 chore: stop publishing skills to npm (#1730)
* chore: stop publishing skills to npm

* fix: align simulator skill startup

* docs: align agent setup with open-first workflow
2026-08-11 11:46:33 +02:00
Michał Pierzchała 62001cf210 refactor(record): derive session recording from the publication lifecycle (#1719)
* refactor(record): derive session recording from the publication lifecycle

`SessionState.recordSession` stored an answer the script-publication
aggregate already contained. Every writer set both, but nothing made them
agree, and #1533 was the consequence: a `--save-script` ingress re-armed
the flag behind an ABORTED status, and a bare `close` published a
recording the caller had been told was aborted.

That fix routed every write through one rule, which made the two agree
without making disagreement unrepresentable. The field remained a second
source of truth, and its doc comments had to carry the invariant that a
type could enforce.

Remove the field and derive the answer. `isRecordingPublication` reads
recording off the lifecycle: ordinary authoring records only while ARMED;
a repair transaction records for its whole lifetime, terminal statuses
included. That last clause is deliberately exact rather than merely safe —
`armRepairStep` armed the old flag and neither `abortRepair` nor
`commitRepair` ever cleared it, so narrowing it would silently stop
evidence capture for a committed repair. Whether it should is a real
question, and a behavior change, so it is left alone here.

What this buys, beyond one less field:

- `buildNextOpenSession` and `finalizeOrdinaryCloseScript` make no
  recording decision at all now, so no surface can arm recording without
  moving the lifecycle that authorizes it.
- The writer's publication gate is answered entirely by the aggregate. Its
  separate ABORTED check is gone: a terminal authoring lifecycle is
  already not recording, so one question replaces two that could disagree.
- The R7 ownership ratchet drops from 23 writer-owned fields / 29 owner
  claims to 22 / 26, and the layering manifest loses the entry whose
  comment documented the smell ("deliberately set on its own by paths that
  record without arming a publication").

Behavior-preserving: the derivation reproduces what the flag held at every
transition. The test fixtures that armed `recordSession` with no
publication state described a shape production stopped producing at #1478;
they now carry the lifecycle that causes recording.

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

* test(close-script): flush queued event-log writes before removing the tmp root

CI failed the Coverage lane with ENOTEMPTY removing the test's tmp root,
in `afterEach` rather than in an assertion.

`SessionStore.recordAction` QUEUES its event-log append
(`queueEventLogWrite`) instead of writing it, and every close path in this
file records an action. Nothing awaited that write, so `fs.rmSync(root,
{recursive: true})` could race it: the pending append recreates
`<root>/sessions/<name>/` while rmSync is walking, and the final rmdir
fails ENOTEMPTY. It needs CI's parallel load to lose the race — the file
passes 12/12 in isolation locally.

Await `flushSessionEventLogWrites()` before removing. The hazard is latent
in any test that records actions and then removes its tmp root; this fixes
the file that failed rather than sweeping the pattern, which deserves its
own change.

Not added to the #1419 contention-retry list: that list requires a
concrete spawn/wait mechanism named per entry, and this file has none. The
race was a real teardown bug, not lane contention.

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

* docs: correct ADR 0016 on recording vs publication for repair

Review caught a real overstatement. The amendment claimed evidence capture
and publication authorization are "the same question asked of the same
state". That holds for ordinary authoring — ARMED both records and
publishes, ABORTED and PUBLISHED do neither — but not for repair:
`isRecordingPublication` is true for every repair status including
`committed` and `aborted`, while the writer additionally applies
`isRepairArmedWriteBlocked`, refusing a committed transaction and one that
is not yet committable.

State it as it is: both decisions derive from the same aggregate, but they
remain distinct predicates, and collapsing them would republish a committed
repair or commit an incomplete prefix.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 10:58:03 +02:00
Michał Pierzchała 1b2e786128 refactor: move screen recording onto platform runtime (#1724) 2026-08-11 10:24:57 +02:00
Michał Pierzchała 3cbdb0ac75 fix: prevent package cleanup race 2026-08-10 21:33:47 +02:00
Michał Pierzchała c2c81549d9 feat: add simulator verification skills (#1716)
* feat: add simulator verification skills

* chore: simplify simulator skills

* docs: refine simulator skill guidance

* test: guard simulator skill workflows

* style: format simulator skill contract test
2026-08-10 21:15:53 +02:00
Michał Pierzchała 05a1d76f2e test: add daemon RPC wire-surface compatibility gate (#1717)
* test: gate daemon RPC wire compatibility against the last released tag (#1432)

ADR 0006 fixes exactly when DAEMON_RPC_PROTOCOL_VERSION must be bumped, and
nothing checked that it was. The runtime guard (readRemoteDaemonHealth) refuses
a mismatched peer, but only fires when someone remembered the bump — a wire
change that skipped it left both sides advertising protocol 2 while parsing
different payloads, which is the failure ADR 0006 exists to prevent.

Local daemons cannot skew (isReusableDaemonInfo takes over on any package
version mismatch). Cross-machine is skewed by design — proxy, cloud/limrun, a
remote macOS host — and ADR 0006 explicitly rules package version out as the
compatibility gate there, so the one boundary where skew is intended was the
one boundary with no gate.

test/wire-compat/surface.ts declares the wire surface grouped by the ADR bullet
each group serves, quoting it, with an `uncovered` note where a bullet is only
partly digestible (the /health and /rpc literals inside http-server.ts stay
reviewer-owned: a moved route 404s at connect time rather than misparsing).
ledger.json records what each declaration hashes to, at which protocol version.

Two gates, split for the same reason the replay-compat corpus splits:
- unit-core holds the ledger to its source and prints the digest to paste;
- Released-Surface Compatibility reads the ledger at the last RELEASED tag and
  requires the drift since then to carry a bump or a compatibleChanges ack.

From one commit a bumped ledger and an unbumped one are both just an edited
file, so only a released baseline can tell them apart. Acks are keyed by the
digest they cover, so one "added an optional field" cannot launder later
changes. Digests ignore comments and formatting; the manifest's closure is
derived from the AST, so a field typed by an unlisted sibling fails rather than
sitting outside the gate.

CI cost: one added job (checkout + toolchain + two node scripts, ~1 min),
mirroring the existing full-history replay-compat job.

* test: close wire-surface overclaim and make the closure fail closed (#1432)

Addresses both review P1s on #1717.

P1 — the manifest materially overclaimed ADR 0006 coverage. It quoted all four
bullets while digesting only the payload TYPES, so the producer and consumer
seams could break a skewed peer without moving a listed digest. Now listed on
both sides of every boundary: JSON-RPC method sets and the projections that
turn each method's params into a DaemonRequest, createRpcError/sendJson/
writeRpcResponseEnvelope, resolveToken and the auth-hook types, upload
preflight/finalize/308 handlers and the resumable ticket shape, artifact route
and download/inventory framing, REST error mapping, and the client's own
payload builder, lease-method mapping, response parser and error projection.
57 -> 117 declarations.

What stays out is now named rather than implied: createDaemonHttpServer's
dispatch wiring and the /health and /rpc literals inside it. Everything it
dispatches WITH is digested individually, and a moved route 404s at connect
time rather than misparsing — the loud failure, not the silent one.

P1 — imported and re-exported payload shapes escaped the closure.
declarationHomes() scanned only the manifest's own files and the walk
continued silently when a name could not be placed, so a listed type could
gain foo?: ImportedShape from a new module and stay green. Resolution is now
explicit and fails closed: relative imports, workspace specifiers (through the
owning package's own exports map, so a re-pointed export cannot drop a type),
and facade re-export chains. Every referenced name must land on a listed
declaration, a waiver with a written reason, a declared external module, or the
TS/Node global set. Fixed two extractor blind spots the walk exposed: a
declaration's own generic parameters and `as const` were being reported as
references.

Planted-red proofs (wire-mutations.test.ts): 13 cases independently mutate
method naming, response serialization, response parsing, auth projection,
upload ticket shape, 308 framing, artifact framing, REST error mapping, and
progress framing, each asserting the digest moves; 3 probes prove the closure
really reaches across a package boundary, a facade re-export, and a plain
relative import. Mutations apply inside the declaration's own span — a
whole-file replace silently hit a sibling sharing the substring, which is how
the first draft of one case passed vacuously.

The largest waiver pair (InternalRequestOptions, CommandFlags) rests on ADR
0006's own additive rule: they reach the peer inside DaemonRequest's untyped
flags/input bags, and the decision says a new flag needs no bump. Digesting
them would fire the gate on every new CLI flag and train reviewers to
rubber-stamp acks.

* test: list the consumer half of the auxiliary HTTP boundaries (#1432)

Addresses the remaining review P1 on #1717. The manifest claimed both sides of
response/upload/artifact framing while listing nothing from upload-client.ts,
daemon-artifacts.ts, or the health consumer in daemon-client-transport.ts, so
those parsers could narrow without moving a listed digest or protocol 2.

Now listed (117 -> 141 declarations):

- /health consumer: RemoteDaemonHealth, readHealthPayload, readDaemonHttpHealth,
  readRemoteDaemonHealth. This is the sharpest of the three — narrowing the
  reader or the comparison disables the very refusal ADR 0006 exists to
  guarantee, and nothing else in the repo would notice.
- /upload consumer: UploadResponse, UploadPreflightResponse, UploadPreflightResult,
  parseUploadPreflightResult, requestUploadPreflight, uploadDirectArtifact,
  tryDirectUploadWithResume, shouldRetryDirectUpload, finalizeDirectUpload,
  uploadLegacyArtifact, ARTIFACT_HASH_ALGORITHM, isStringRecord, and
  PreparedUploadArtifact — whose sha256/sizeBytes/fileName/artifactType/
  contentType fields ARE the preflight body the daemon parses.
- /artifacts/* consumer: DaemonArtifactEndpoint, buildDaemonArtifactUrl,
  isRemoteDaemon, DownloadRemoteArtifactParams, downloadRemoteArtifact,
  materializeRemoteArtifacts, resolveMaterializedArtifactPath.

Running the closure fail-closed over the new files surfaced three more stops,
each decided rather than skipped: PreparedUploadArtifact listed (it is payload),
UploadProgressSink waived (client-local rendering, never leaves the process),
and src/daemon/types.ts#DaemonArtifact waived as a re-export alias of the listed
kernel type, matching its DaemonRequest/DaemonResponse siblings.

10 more planted-red mutations cover the new seams: health version-read and
mismatch-refusal defeated, RemoteDaemonHealth field dropped, preflight parser
narrowed, preflight/legacy response shapes narrowed, finalize body key renamed,
ticket field renamed, artifact tenant header dropped, artifact URL moved. A
fourth closure probe proves the upload-consumer files are genuinely reached by
the walk rather than merely listed. 22 -> 33 tests.

The README now states the coverage as a producer/consumer table per boundary,
so the claim is checkable at a glance instead of asserted in prose.

* test: list the client half of the resumable 308 contract (#1432)

Addresses the third review P1 on #1717. Listing the daemon's
handleResumableUpload proved it still PRODUCES 308; nothing proved the client
still CONSUMES the released one. src/remote/upload-stream.ts owns that half and
was entirely outside the manifest, so a newer client could stop accepting
`upload-offset`, change how it reads `Range: bytes=0-N`, or emit a different
resumed `Content-Range` without moving one of the 141 listed digests.

Now listed (141 -> 151): UploadStreamResponse, streamFileToHttpRequest,
streamFileToHttpRequestAttempt, buildUploadRequestHeaders, isUploadResumeStatus,
isUploadRedirectStatus, parseUploadResumeOffset, parseNonNegativeIntegerHeader,
firstHeaderValue, MAX_UPLOAD_REDIRECTS.

streamFileToHttpRequestAttempt is listed despite its size, unlike
createDaemonHttpServer which stays in `uncovered`. The distinction is stated at
the declaration: the HTTP server only dispatches to handlers that are each
digested, while the attempt loop IS the resume state machine — it decides
whether a 308 continues the upload and what the next request carries, so its
sequencing alone can break a released daemon while every helper keeps its digest.

6 new planted-red mutations prove the client half moves the ledger: a dropped
`upload-offset` fallback, narrowed Range parsing, a changed resumed
Content-Range, 308 no longer treated as continue, a narrowed UploadStreamResponse,
and dropped header-value coercion. 33 -> 39 tests.

Closure fail-closed surfaced two more stops: UploadStreamProgressOptions waived
(local byte-progress rendering) and URL/URLSearchParams added to the global set.

README now carries a `/upload` resume row in the producer/consumer table, and
names the pattern behind three rounds of review: the coverage sentence kept
getting written ahead of the coverage, so the table and the `uncovered` notes
are the claims to trust — they are checkable against surface.ts, prose is not.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 20:52:29 +02:00
Michał Pierzchała cdc754e6ed perf: speed up iOS agent recovery and streamline CLI guidance (#1700)
* Avoid interactive children in parent taps

* docs: streamline no-skill CLI help

* perf: recover faster from sparse iOS trees

* fix: preserve selector context for blocked parent taps

* fix: preserve coordinate text-entry focus

* fix: preserve thin parent touch targets

* fix: fail closed for unscoped iOS typing

* test: isolate replay lock fixture

* test: share node integration process
2026-08-10 20:43:01 +02:00
Michał Pierzchała b15c502318 refactor: extract platform network runtime (#1702)
* refactor: extract platform network runtime

* fix: preserve platform network recovery routes

* test: guard network parser placement
2026-08-10 17:58:42 +02:00
Michał Pierzchała b1ed5353d1 refactor: extract platform log runtime (#1701)
* refactor: extract platform log runtime

* fix: clear terminal app log recovery markers

* fix: preserve scoped app log tooling

* fix: preserve app log cancellation

* fix: handle large changed coverage diffs

* fix: harden Limrun runtime identity

* refactor: tighten platform log runtime

* fix: close app log trust gaps

* fix: accept canonical session path aliases

* refactor: extract durable capture kit

* fix: refresh retained log marker admission

* fix: rotate app logs after process relaunch
2026-08-10 17:58:42 +02:00
Michał Pierzchała b7470698a0 fix(test): include workspace packages in changed-line coverage (#1711)
The full Vitest coverage run instruments both root `src/` and
`packages/*/src/**`, but the changed-line gate's prefilter rejected every
package path before consulting LCOV. A PR could add uncovered lines to
contracts, selectors, kernel, or provider packages while the 70%
changed-line gate reported no package denominator.

Make the source-root predicate package-generic so it accepts both
coverage roots, while package tests (`.test.ts`, `__tests__/`),
package-level `test/`, and `.tsx` stay excluded as before. Scoring,
waivers, the excluded-line tally, and branch reporting are unchanged.

Pinned at both levels: the classification matrix and a pure-model
scoring regression in model.test.ts, plus a temporary-repository
regression in run.test.ts proving the executable gate fails and names the
uncovered package path and line.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 15:58:51 +02:00
Michał Pierzchała 4af1307024 test: cap Vitest workers for parallel worktrees (#1710)
* test: cap vitest workers for parallel worktrees

* perf: leave Vitest workers uncapped in CI
2026-08-10 15:19:54 +02:00
Michał Pierzchała c06bed9f77 refactor: extract platform device inventory runtime (#1699)
* refactor: extract platform inventory runtime

* fix: preserve scoped Apple inventory tooling

* fix: preserve Apple tool cancellation

* refactor: tighten platform inventory boundaries
2026-08-10 12:51:59 +02:00