363 Commits

Author SHA1 Message Date
Michał Pierzchała 9aa6465768 perf(scripts): add a device-free PNG crop benchmark (#2505)
* perf(scripts): add a device-free PNG crop benchmark

`pnpm bench:png-crop` runs the whole-image pipeline and the shipped region crop
over the same bytes in one process, so the comparison holds the capture content,
the deflate stream, and the machine fixed. The corpus is generated, which keeps a
run at seconds with no device; real captures join the same table via `--file`, and
each corpus entry prints its compressed size so an unrealistic corpus is visible.

The README records what the measurements said, including the case the encoder
policy loses: `None` on every scanline is faster everywhere but writes about 1.7x
more bytes than a filtered encoding on smooth low-frequency content.

* chore(gates): run the PNG crop benchmark's model tests in unit-core

Registers scripts/png-crop-benchmark/*.test.ts so the timing summary that the
report is built from stays covered without a device lane.
2026-09-12 18:23:28 +00:00
Michał Pierzchała 47b1cae548 fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484) (#2486)
* 0.21.1

* fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484)

Since 0.21.0 the host AX bridge is the snapshot source for local iOS
Simulators. It reads one process, and a WebKit page lives in another:
Safari and WKWebView screens were published as chrome plus empty webview
nodes, with no ref reaching the page.

The decoder now counts AXRemoteElement leaves that sit under a WebView
ancestor and reach the viewport, and the source refuses such a tree as
remote-content-boundary. The existing route fallback serves XCTest, which
resolves remote elements, for the rest of the app generation and discloses
the switch in the snapshot warning. Frameless leaves are refused; zero-area
and off-screen ones are published.

Adds a fixture-backed smoke scenario that drives the WebView lab through the
default route, amends ADR 0004 and the bridge README, and shares the e2e
snapshotNodes helper.
2026-09-11 13:12: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 6486788359 0.21.0 2026-09-08 21:59:28 +02:00
Michał Pierzchała c5d9707196 refactor(daemon): move the device selection cluster into @agent-device/device-selection (#2396)
* refactor(daemon): move the device selection cluster into a workspace package

git-rename src/core/dispatch-resolve.ts, src/core/device-selection-resolver.ts and src/request/device-inventory-context.ts (plus their tests and fixtures) into a new private package @agent-device/device-selection (deps: contracts, host-kit, kernel). One subpath per module points straight at the moved file; no index.ts, no re-export at the old path. Consumers switch to the owning specifier in the follow-up commit.

* refactor(daemon): install the installed-app probe on the inventory gateways and switch consumers to the device-selection package

The composition root (src/platform-runtime-device-inventory.ts) now attaches an optional findInstalledApp probe to the composed device inventory gateways, lazily importing the Apple simulator app-resolution mechanics. Device selection reads the probe from the request context instead of importing a platform package directly, so absent probes fall back to the ordinary inventory rules. All consumers switch to the @agent-device/device-selection/{dispatch-resolve,device-selection-resolver,device-inventory-context} subpaths; the moved tests carry a package-local inventory test util.

* chore(gates): re-point the layering gates at the device-selection zone

TARGET_DAG_RANK gains the device-selection leaf zone and drops the retired request zone; the back-edge fixture and the capture-kit ALS substrate fixture move to the package path.

* test(daemon): prove the factory-installed app probe narrows simulator selection

The moved selection tests inject their own probe, so nothing covered the
composition root actually attaching findInstalledApp to the gateways:
omitting it would leave those tests green while app-based selection
silently fell back to the generic local rules.

The new case runs two booted simulators through
createComposedDeviceInventoryGateways and the request context, fakes
only the leaf xcrun spawn (core tool-provider), and asserts the
single-app-installed-local selection plus both probe consults.
2026-09-08 21:51:41 +02:00
Michał Pierzchała 233a34d138 refactor(daemon): extract the session event journal into a workspace package (#2361)
* refactor(daemon): extract the session event journal into a workspace package

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 07:52:27 +02:00
Michał Pierzchała 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 e0f8c55f6e refactor(move): move managed device allocation into its own workspace package (#2316) (#2321)
* refactor(move): move managed device allocation into its own workspace package (#2316)

* chore(gates): unrank the managed-allocation zone, declare its durable-json seam, and ignore its unconsumed root dependency (#2316)
2026-09-06 07:32:25 +02:00
Michał Pierzchała 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 a90d95aab7 perf: bundle tar-stream to reduce install footprint (#2286)
* perf: bundle tar-stream to reduce install footprint

* fix: align bundled archive dependencies and simplify config
2026-09-05 20:37:34 +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 33084c7748 perf(ios): decide Simulator AX bridge viability (GO, Node-direct guest reader) (#2237)
* test(ios): add guest simulator AX bridge evidence

* test(ios): make alert cleanup selector unique

* test(ios): admit recovered alert cleanup surface

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

* docs(ios): record guest AX bridge decision

* chore(ios): remove unused spike import

* docs(ios): correct simulator bridge verdict

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

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

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

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

* test(ios): narrow Simulator bridge decision evidence

* docs: publish Simulator bridge evidence out of tree

* fix: tighten iOS bridge evidence gates

* docs: publish corrected bridge evidence

* docs: point to post-rebase bridge evidence
2026-09-04 07:38:36 +02:00
Michał Pierzchała 941ca0e7e0 ci: enforce the image-size parser mitigation through a test-app gate (#2269) 2026-09-03 21:36:11 +02:00
Michał Pierzchała fbf914b907 chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch (#2251)
* chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch

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

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

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

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

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

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

EVIDENCE_TAG, EVIDENCE_COMMIT, and missingPublishedEvidence are used only
inside evidence.ts. runEvidenceReport is the CLI entry point invoked
through the file's own `node evidence.ts` guard, not through an import,
matching the unexported runDeepButtonRule pattern in the sibling
deep-button.ts script.
2026-09-03 17:17:40 +02:00
Michał Pierzchała 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 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 a8ee397168 test(ios): add snapshot engine conformance gates (#2213)
* test(ios): add snapshot engine conformance gates

* test(ios): align differential acquisition inputs

* fix(ios): gate Swift differential on macOS

* test(ios): keep differential coverage host-aware

* test(ios): own snapshot differential on macOS
2026-09-01 15:59:31 +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 0ecfef1c17 refactor(platforms): sink the shared src/platforms root files into their substrate homes (#2100)
* refactor(platforms): sink the shared src/platforms root files into their substrate homes (#2082 W3)

The shared files left directly under src/platforms move onto a declared
owner: provisioning mechanics (install-source family, toolchain probing,
boot-failure classification, app-resolution caching) form
@agent-device/provision-kit above capture-kit; host mechanics resolve to
host-kit's seams; kernel takes the pure numeric helpers; contracts keeps
vocabulary only.

Settings parsing, command-attempt rendering, and the unsupported-interactor
factory stay with their families rather than pooling in a substrate
package: android and apple settings each own their parsing, and the
unsupported-interactor factory lives in root core with a vega-local copy.

A platforms-root-shape rule rejects any new shared file or directory
appearing directly under src/platforms, and the provision-kit direction
gates (no platform imports in, no capture-kit importer) are planted red.

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

* test: cover the family-owned parsers and the unsupported-interactor factories

The settings parsers and the unsupported-interactor factory arrived without
owning tests, so their branches rode on callers. Each now has one: the
appearance/state parsers over every accepted spelling and their rejections,
the attempt summarizer over its arg join and stderr budget, and both
interactor factories over the whole operation surface and the per-instance
label.

Also drops the duplicate ./snapshot-desktop-projection export key that a
rebase left in capture-kit's manifest, where JSON silently keeps the last,
and the root-shape docblock the violation message already states.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:50 +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 057b2da233 ci: run coverage in one job again (#2079)
The Coverage lane was split into two matrix shards plus a Coverage Report
job that downloaded both blob reports and merged them. That claimed three
runner slots per PR and put a barrier in front of the merge: the report
job could not start until the slower shard finished, and the blobs it
waited on are tens of MB to upload and download.

One job asks for one slot and reports its own thresholds where it runs, so
the lane finishes when the suite finishes. Everything the split needed goes
with it: the shard/merge switches in vitest.config.ts, the blob reporter
swap, the zeroed per-shard thresholds, and the env blanking that
`test:fuzz-worker` carried only to keep the second leg from inheriting them.
2026-08-27 13:28:54 +02:00
Michał Pierzchała 608bf7aa47 Harden the MCP surface: registry rug-pull fix, operator-only credentials/endpoints, device-shell argv gate, declared timeouts (#2023)
* chore(release): keep the version on main distinct from every published version

Registry scanners diff the repository's tool surface per version string, so a
released number left on main while main keeps changing is indistinguishable
from a republished ("rug-pull") version — two scans of the same version see
two different tool sets (AS-012).

- release:publish now runs release:mark-dev after npm publish, moving
  package.json and the synchronized server.json to the next patch with a
  -dev prerelease marker.
- release:prepare refuses to publish while the -dev marker is in place, so
  a forgotten version bump cannot ship a prerelease as latest.
- Mark the current tree 0.20.11-dev: main had been sitting on the published
  0.20.10 while the tool surface kept changing, which is the live finding.

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

* fix(mcp): remove credential inputs from the model-writable tool surface

Every MCP tool advertised daemonAuthToken (and the Metro tools bearerToken)
as a free-form string the model writes. The model both reads untrusted app UI
text and picks tool arguments, so on-screen text steering it to set a token
was a prompt-injection exfiltration path. Credentials are operator-owned:

- the keys are omitted from every advertised tool schema (MCP and AI SDK,
  which share listCommandTools()),
- an explicit value is refused with env-var guidance instead of being
  forwarded (the retired-field posture: refuse, never silently drop),
- operator-sourced values are untouched — env/config defaults still merge,
  and the daemon and Metro clients keep their AGENT_DEVICE_DAEMON_AUTH_TOKEN
  / AGENT_DEVICE_METRO_BEARER_TOKEN fallbacks. CLI flags are unchanged.

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

* fix(mcp): move operator endpoints and paths off the model-writable surface; declare timeouts

Follow-up to the credential removal: daemonBaseUrl and the Metro
proxyBaseUrl are the endpoints the env-resolved tokens are SENT to, so a
model-writable value redirects the operator's token to an arbitrary server —
same exfiltration path, one step removed. stateDir, cwd,
iosSimulatorDeviceSet, and the three iosXctest* paths select operator
infrastructure, never per-call work. All of them leave the advertised
MCP/AI-SDK tool schemas and are refused as explicit input with env/config
guidance; operator env/config defaults keep flowing exactly as before
(config-backed defaults still merge, and explicit input can no longer
override them). Dropping these shared properties also cuts tools/list
substantially.

Every tool description now also declares its enforced client timeout
envelope (90s default, 180s install, unbounded only for the streaming test
runner), sourced from the descriptor registry's timeout policy so the
declared number cannot drift from the enforced one (answers AS-011, which
read the undeclared envelope as "no timeout").

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

* feat(ci): inventory every dynamic value reaching a device shell

adb shell, adb exec-out, and hdc shell join their argv into one string the
device's sh evaluates, so any unquoted dynamic element is a potential argv
injection — the class of bug the audit found (and fixed) on input text and
cmd clipboard set text. Nothing enumerated the surface, so a new call site
could regress it silently.

scripts/shell-argv is an AST-based gate (oxc-parser, same as di-seams and
layering) keeping an exact inventory of every dynamic device-shell argv
element, keyed by (file, expression) with counts: 121 values today. A new
or grown entry fails CI until the author quotes it through shellQuoteIfNeeded
or records it with --update in the same PR, making "a new value now reaches
the device shell" a reviewable diff; a stale entry fails the other way so
the inventory always matches the code. Wired as the shell-argv gate in the
lint lane and registered in the check catalog.

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

* fix(shell-argv): satisfy the fallow audit without suppressions

The Compatibility & Provenance lane's fallow audit flagged the new gate:
main was an unused export (only the self-run guard consumed it) and four
functions sat over the complexity thresholds. Restructure instead of
suppressing: the AST walk dispatches through a composite-child-field table,
the argv detection is hoisted out of the visitor, drift reporting moves into
helpers, and main is no longer exported. Behavior is unchanged — the model
tests pass as written and the regenerated inventory is byte-identical
(121 values).

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

* fix(test): close the port-reuse race in the unreachable-takeover test

Coverage (1) failed once in CI with the takeover notice missing while the
response still came from the fresh daemon — the exact signature of the
fresh fixture being handed the just-freed ephemeral port: the recorded
daemon becomes reachable and reusable (same version and signature), so the
takeover path is skipped. Bind the fresh fixture before acquiring and
freeing the unreachable port; with no bind after the close, the port can
never be reclaimed. Line-neutral so the size-ratchet pin holds.

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

* fix(mcp): enforce the advertised tool schema at a real admission boundary

P1 (reported by the PR author): hiding operator keys from tools/list did not
stop them reaching the command route. The router forwards raw tools/call
arguments verbatim and resolveMcpConfigDefaults reads them as CLI flags, so an
unadvertised `config`/`remoteConfig` key loaded an arbitrary file whose
daemonBaseUrl/daemonAuthToken then flowed to runCommand — a model-writable
redirect to an attacker endpoint with the operator's token. Reproduced:
{config: <path>} on `snapshot` put both values into the command input.

Replace the per-key operator refusal with a deny-by-default admission boundary
in the shared executor (the one path both the MCP router and the AI SDK adapter
use): every raw input key must appear in the tool's advertised schema, else it
is rejected with guidance BEFORE config/env resolution. This closes the config
loaders, the operator keys, and any unknown key at once, and makes the
advertised additionalProperties:false contract actually enforced. Operator
env/config defaults still resolve — they never arrive as tool input.

Retired keys (maxSize) are admitted so the command's own reader still answers
with migration guidance; they're exposed as metadata.retiredInputKeys for that.

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

* revert(ci): remove the check:shell-argv inventory gate

The PR author correctly flagged that this gate is an inventory, not a
security invariant: --update lets any site self-approve a raw value, and the
literal-first array heuristic is blind to indirect argv (a variable-built
subcommand, or an argv assembled in a helper). Reproduced: adb(['shell',
'input','text',text]) is inventoried, but const s='shell';
adb([s,'input','text',text]) yields no finding. Shipping it security-framed
gives false assurance.

Remove it. The sound fix — a typed device-shell execution boundary where a
raw string cannot reach adb/hdc shell without being quoted or explicitly
marked — is a ~188-site cross-package migration on device execution paths,
scoped to a dedicated follow-up PR. The two known-dangerous sites (input
text, cmd clipboard set text) already quote through shellQuoteIfNeeded on
main, so no regression. This keeps the PR focused on the MCP tool surface.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 15:19:07 +02:00
Michał Pierzchała a830ac8df2 feat: add Linux command evidence lane (#2017)
* feat: add Linux command evidence lane

* fix: assert Linux find result shape

* fix: read Linux find result envelope

* fix: reset Linux calculator before diff

* fix: release Linux session before reset

* fix: guard Linux evidence session reset

* fix: forward Linux evidence timeout

* fix: tighten Linux evidence assertions

* fix: preserve Linux replay session identity

* fix: close Linux replay session before reset

* fix: share Linux evidence daemon state

* fix: keep Linux swipe evidence in bounds

* fix: keep Linux artifact gap honest
2026-08-25 07:56:13 +02:00
Michał Pierzchała d97a628e38 fix(ci): make the two rg-based static checks actually run (#2006)
* fix(ci): make the two rg-based static checks actually run

ripgrep is never installed on ubuntu-latest, so both `rg` assertions in
the Lint & Format job failed with "command not found" (exit 127) on
every run. `if rg ...; then ... fi` cannot distinguish that from "no
matches" (exit 1) — both read as false, so each step silently passed
without its assertion ever executing. The DI-seams check had 7 live
violations it never reported.

Rewrite both against `grep`, which every runner ships, with match/
no-match/error exit codes handled explicitly so a broken scan fails
the lane instead of reading as a pass, plus a zero-tracked-files guard
so a renamed directory can't quietly go uncovered.

The DI-seam pattern also gets narrower to drop two classes of false
positive surfaced by actually running it: `typeof fetch` (fetchImpl?/
fetch? seams inject the one global with no module boundary vi.mock can
intercept; auth-session.ts/cloud-profile.ts/daemon-proxy.ts exercise
the seam directly in their unit tests, while CLI-level tests use
vi.stubGlobal('fetch', ...) where the seam isn't reachable — a
deliberate, exercised seam) and `typeof SOME_CONSTANT` in
SCREAMING_SNAKE_CASE (derives a literal union type from a constant,
e.g. interaction-touch-response.ts's dispatchPath field — not an
injectable seam at all).

Fixes #1976

* fix(ci): replace the DI-seam name-based allowlist with an explicit per-site one

Review on PR #2006 (#1976): the previous revision fixed the exit-code
handling but decided which `?: typeof X` matches to ban with a regex
that exempted matches by the *spelling* of the typeof target
(`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always
passed). That's a name-based semantic allowlist, not ownership: a new,
genuinely test-only `typeof fetch` seam anywhere in the tree would
have silently passed, while an equally legitimate seam under any
other name would still fail.

Add scripts/di-seams: a small, tested TypeScript checker that judges
each match against an explicit, typed, per-site allowlist
(scripts/di-seams/approved.ts) keyed by (file, field name, typeof
target) rather than by name. A triple is exempt only because it was
individually reviewed and named — never because of how it's spelled —
and the gate fails just as hard on a stale approval (one whose triple
no longer matches anything, e.g. after a rename) as on an unapproved
seam, so the list can't silently drift out of sync with the code it
describes.

Moves the DI-seams step in ci.yml to run after Setup toolchain (it's
no longer a toolchain-free text scan); the Swift trailing-comma check
stays where it was.

* fix(ci): register di-seams as a real gate and route it through the tmpdir wrapper

CI caught two things the local (dependency-free) run couldn't:

- oxfmt formatting on the two new files.
- scripts/node-test-tmpdir.test.ts's repo-wide audit: every package.json
  script that invokes `node --test` directly must route through
  scripts/node-test-tmpdir.ts, or a crash/timeout mid-run leaks its
  scratch TMPDIR. check:di-seams now does.
- check:gate-manifest: a package.json script that runs `node --test`
  must be covered by a registered CHECK_CATALOG gate, or the audit
  reports the test suite as run by no lane. Registered 'di-seams' in
  scripts/check-affected/{model,checks}.ts and wired the CI step
  through run-gate like every other structural guard in this job,
  instead of invoking pnpm directly.

Verified locally with node_modules installed: check:di-seams,
check:gate-manifest, check:gate-manifest:test, check:affected:test,
check:layering, check:fallow (scoped to the changed files), format,
lint, and typecheck all pass.

* fix(ci): close the multiline and duplicate-site gaps in the DI-seam scanner

Review round 2 on PR #2006 (#1976):

- findSeamMatches scanned line by line, so a declaration split across
  lines (`field?:` on one line, `typeof X` on the next) was invisible.
  Matching now runs against each file's whole source in one pass —
  `\s` matches a real newline in JavaScript regexes with no extra flag
  needed — with the line number derived from the match's character
  offset.

- checkSeams keyed approval by (file, field, target) alone, so once
  one occurrence of a triple was approved, any further occurrence of
  that same triple anywhere in the file passed too. The key now
  includes the line the match starts on, so an approval names one
  specific declaration, not a recurring pattern. approved.ts expands
  from 5 collapsed entries to the 7 exact sites this closes down to.

Added regression tests planting both gaps directly (a cross-line
declaration, and a second unreviewed fetchImpl?: typeof fetch at a
different line in an already-approved file) and verified both against
the real tree with injected violations, restored cleanly afterward.
Re-ran the full local gate suite (di-seams, gate-manifest, layering,
fallow, format, lint, typecheck) — all green.

* fix(ci): resync approved DI-seam line after merging main

Merging main (#2002) removed an unused import above the approved
dispatchPath?: typeof MAESTRO_COORDINATE_FALLBACK_PATH declaration in
interaction-touch-response.ts, shifting it from line 61 to line 60 —
exactly the location-specific-approval staleness the gate is designed
to catch, just triggered by an unrelated upstream edit rather than a
change in this PR. Updated the approved line to match.

* fix(ci): replace the DI-seam positional table with a code-local approval marker

Review round 3 on PR #2006 (#1976): CI proved the round-2 fix's core
assumption wrong within one push. Keying approval by (file, line,
field, target) made a line number the identity — an unrelated edit
anywhere earlier in a file shifts every approval below it, and that's
exactly what happened: merging main removed an unused import above
the approved dispatchPath declaration, and the gate rejected an
unchanged, already-reviewed line.

Detection is now AST-based (oxc-parser, the same tool
scripts/layering/*.ts already uses) instead of a source-text regex:
any `{ optional: true, typeAnnotation: TSTypeQuery }` node — a
property signature or a bare parameter — is a candidate, which finds
a multiline `field?:\n  typeof X` declaration for free instead of
needing a special case for it.

Approval is a `// di-seam-approved: <reason>` comment immediately
above the declaration, matching this repo's own `//
fallow-ignore-next-line complexity` convention: the marker precedes
what it exempts. approved.ts (the external table) is deleted — there
is nothing left to keep in sync, since the approval travels with the
code it approves. A second, unmarked seam under the same field/target
elsewhere still fails; reordering unrelated code around an approved
declaration no longer touches it.

Added the marker to the 7 real approved sites (fetch-global
injection seams in auth-session.ts/cloud-profile.ts/daemon-proxy.ts;
the literal-type-derivation false positive in
interaction-touch-response.ts) and regression tests proving: a
cross-line declaration is still found, a second unmarked occurrence
of an approved field/target pair still fails, and an unrelated
insertion above an approved declaration no longer breaks it. Verified
against the real tree with an injected multi-line unrelated insertion
before an approved site — still green. Re-ran the full local gate
suite (di-seams, gate-manifest, layering, fallow, format, lint,
typecheck, auth-session unit tests) — all green.

* fix(ci): reject a di-seam-approved marker with no reason text

Review round 4 on PR #2006 (#1976): approvalReason() returned '' (not
null) for a bare `// di-seam-approved:` comment with nothing after
it, and checkSeams() only filtered out null, so an empty marker
silently approved a seam with zero justification — exactly the kind
of unreviewed bypass this gate exists to prevent.

approvalReason() now returns null when the joined reason text is
empty after trimming, so a bare or whitespace-only marker is treated
the same as no marker at all. Added tests for both the model-level
behavior and the end-to-end checkSeams() result, plus verified
against the real tree by injecting a bare-marker declaration and
confirming it's flagged, then restored cleanly.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 21:31:24 +02:00
Michał Pierzchała dbc4f2f955 chore(test): start the subprocess-stub kill-criterion experiment (#1823) (#2007)
Deletes the serialized `subprocess-stub` Vitest project and drops
SUBPROCESS_STUB_TESTS from unit-core's exclude, so its two real
spawners (client-metro.test.ts, harness.test.ts — corpus-replay.test.ts
already left for fuzz-worker in #1994) run un-serialized in the default
forks pool per #1823's own kill criterion. Revert if a timeout-shaped
failure shows up before 20 consecutive CI runs pass clean.

The files stay excluded from the mutation lane (SERIALIZED_TESTS):
that exclusion is about mutant-rerun cost, independent of Vitest
project structure. Updated the comments/docs/scripts that described
the old project by name so none of them assert a project that no
longer exists.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 20:07:47 +02:00
Michał Pierzchała 893ce4b866 fix(ci): repair nightly XCTest and conformance lanes (#1989)
* fix(ci): repair nightly XCTest and conformance lanes

* fix(ci): harden nightly failure classification

* fix(ci): stabilize macOS replay cleanup

* fix(ci): close nightly review gaps

* fix(ci): classify device claims as infrastructure
2026-08-24 19:29:33 +02:00
Michał Pierzchała 104fe75248 fix(ci): run the fuzz corpus replay outside the coverage lane (#1994)
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies, and neither a missed
per-case budget nor STARTUP_BUDGET_MS is implicated. Partial test counts
(3/11 and 9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

The second leg goes through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. ci.yml sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them and the shard would die: Vitest refuses
`--shard=1/2` over this one-file project, and the blob reporter
overwrites the instrumented shard's report on its way out. Verified on
the merged tree — shard 1/2 (549 files), shard 2/2 (548), and the merge
job (1097 files, 90.38% lines) all pass, and the leg still fails without
the blanking.

Refs #1824
2026-08-24 17:03:25 +02:00
Michał Pierzchała 04e4c23b95 dx(check): fail fast when node_modules lags the lockfile (#1967)
* dx(doctor): flag a worktree whose node_modules lags the lockfile

Add a doctor probe and a check:affected preflight that compare
node_modules/.pnpm/lock.yaml (the exact lockfile snapshot pnpm installed
from) against pnpm-lock.yaml via a content hash — no subprocess. On
mismatch both surfaces report the same one-liner: "node_modules was
installed from a different lockfile; run pnpm install", so a stale
install names its own cause instead of surfacing as bogus format diffs
on files a change never touched (the #1956 incident).

Closes #1963

* fix(doctor): scope the node-modules probe to a local source checkout

The probe ran unconditionally from findProjectRoot(), so it fired in two
contexts it cannot diagnose:

- Packaged installs. Published packages ship neither pnpm-lock.yaml (not
  in the package.json `files` allowlist) nor an installed snapshot, so
  every end user's `doctor` gained a spurious node-modules line and a
  degraded overall status.
- `--remote`, where the daemon's own root describes the server
  deployment rather than the caller's worktree, so the answer could not
  address #1963 at all.

Whether a root is a source checkout is now decided by the presence of
pnpm-lock.yaml itself rather than a heuristic about install location, and
'no-source-checkout' is a distinct result rather than a warning, so the
packaged case cannot be represented as a defect. The probe returns
undefined there and the route appends no check, matching how doctor
already models an out-of-scope question (the device family is likewise
absent under --remote). The fresh-worktree catch is preserved: a lockfile
with no installed snapshot is still a hard failure.

The check:affected preflight is unchanged in behavior.

Route-level assertions cover all three contexts (source, packaged,
remote); each was verified to fail against the pre-fix wiring.

* refactor(check): keep stale-install probe worktree-local
2026-08-22 17:01:04 +02:00
Michał Pierzchała d57aa69777 test: add macOS platform command coverage manifest (#1922)
* test: add macOS platform command coverage manifest

* fix: remove unused macOS coverage type exports

* test: route macOS coverage away from iOS lane

* fix: account for host-dependent macOS audio capability

* fix: run macOS coverage manifest in CI
2026-08-21 12:39:35 +02:00
Michał Pierzchała b92ce95e0a chore: refresh root development dependencies (#1923)
* chore: refresh root development dependencies

* fix: keep upgraded tooling compatible with CI

* fix: keep Expo config lint coverage

* fix: restore Expo fixture lint coverage
2026-08-21 12:35:57 +02:00
Michał Pierzchała 40e4b0dd3e docs(agents): restore and enforce progressive disclosure (#1888)
* docs(agents): restore and enforce progressive disclosure

* test(maestro): pin typed selector fallback signal

* docs(agents): address progressive disclosure review

* docs(agents): restore orphaned traps and close guidance-gate bypasses

- AGENTS.md: skills carry a minimal start/routing card; command semantics
  stay in versioned CLI help (the skills contract enumerates two skills by
  hand, so prose retains ownership for the rest)
- testing.md: restore the two local-only XCTest snags CI never hits
  (unsigned-bundle policy refusal signature + first-run automation permission)
- scripts/gate/routing.ts: record GitHub's 300-changed-file path-filter limit
  at the paths-ignore assertion it bounds
- agent-guidance-contract.test.ts: recurse docs/agents so nested guidance
  cannot evade the byte budgets while the gate stays green
2026-08-20 16:58:26 +02:00
Michał Pierzchała 393eb30a28 ci: give check:affected real Apple ownership rules and route ios.yml on them (#1781 A9-2) (#1857)
* ci: give check:affected real Apple ownership rules and route ios.yml on them (#1781 A9-2)

Device-lane ownership by platform family in the affected selector
(scripts/check-affected/device-lanes.ts): a TypeScript-only Apple change now
carries replay-ios/replay-ios-device/replay-macos in a narrow plan, other
families own only their own lanes, shared runtime surface owns every lane,
unit tests own none. Golden tables (contracts/fixtures) own the parity unit
test and both runner builds instead of failing open.

ios.yml pull_request paths-ignore is routed on that ownership; the gate
manifest asserts the list against the selector over every tracked path both
ways (scripts/gate/routing.ts, ROUTED_LANES). push to main is unfiltered.
Path coverage exempts declared manual-only checks the way owned does.

* ci: tighten routing assertion shape (fallow: unused exports, complexity)

* ci: name parked checks in check:affected --run skips

* ci: bound the routed-lane exemption to sibling workflows (review of #1857)

The exact-name .github exemption was unbounded: naming the lane's own
setup-apple-runner-build or boot-ios-test-simulator action skipped the lane
that runs them and the manifest stayed green. Lane now carries the transitive
composite-action closure plus its own workflow file (Lane.uses, same walk
declaredGates does), and the exemption refuses anything in it.

Also: an unowned path under an ignored root (a non-TS fixture under a family
root) asked for the ignore entry to be removed, which would un-route every
sibling in that tree; it now asks for a selector owner. Both cases pinned,
both proven red against the pre-fix code. Documents GitHub's 300-changed-file
path-filter limit in docs/agents/testing.md.

* ci: close the routed-lane exemption over composite-action support files

Lane.uses recorded only each composite action's action.yml, so a support file
the descriptor executes was exemptible as if it were an unrelated sibling
workflow: ios.yml uses setup-fixture-app, whose action.yml runs
"$GITHUB_ACTION_PATH/fetch-artifact.sh", and that script runs its siblings
resolve-artifact-name.sh and trusted-artifact.mjs — references that exist only
inside shell, one level past anything YAML parsing sees.

The closure unit is the action's directory now. It needs no shell model and
cannot miss a file however deep the reference chain runs; the coarseness is
harmless because a file in an action's own directory belongs to that action.
All three files pinned, red against the descriptor-only closure.
2026-08-19 17:35:23 +02:00
Michał Pierzchała fda81c5121 0.20.10 2026-08-18 21:30:43 +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 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 f843dc2df1 fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1) (#1820)
* fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1)

`pnpm gate replay-android` failed 4/8 whenever it ran after the full-tier Android E2E
(replays-nightly run 32107665052, job 95620294899): 05-app-lifecycle, 06-swipe-gestures and
both fixture replays diverged under "A system surface covers the app". The E2E was not the
cause. Reproduced on a pixel_7 / API 36 AVD with the same cutout geometry CI's
`avdmanager --device pixel_7` produces (status bar 136px, not the 63px of a plain 1080x2400
skin):

- `03-scroll-discovery.ad` runs `scroll up 3`. The scroll planner clamps travel to the viewport
  minus a 5% band, so the touch-down landed at y=120 — inside the 136px status bar — and
  pulled the notification shade instead of scrolling. On API 36 the app window is
  edge-to-edge, so the reported viewport starts at y=0 and includes that bar.
- The shade then covered every replay until `04`'s `back` closed it. Native readdir order on
  the runner (03, 05, 06, fixture/02, fixture/01, 04, 01, 02) put four files in that window;
  the last green run (2026-07-30) had 04 right after 03, so the pull was masked.

Fix in the product, not the lane: DEFAULT_EDGE_PADDING_FRACTION 0.05 -> 0.1 in the TS scroll
planner and its Swift port. Every real Pixel has a cutout (5.7% of a Pixel 7's height) and an
iPhone's Dynamic Island status bar is 6.9%, so any saturated `scroll up` opened the shade /
Notification Center for real agents too. Parity vectors updated in both suites plus a Pixel 7
regression vector (1080x2400, amount 3 -> touch-down y=240 > 136).

Second contamination the same order exposed once the shade was gone:
`fixture/02-selector-routes-covered-diagnosis.ad` is a #1715 reproduction recipe that FAILS
BY DESIGN at step 9 (covered-target refusal) and leaves the device in landscape, yet the
gate enumerated `test/integration/replays/android` recursively. iOS keeps gate replays in
`replays/ios/simulator` and fixture recipes in `replays/ios/fixture`; Android now mirrors that:
the six Settings replays move to `replays/android/emulator`, `test:replay:android` points there,
and `fixture/` stays E2E-owned (`full:fixture-replays` already runs 01 by path). android.yml
and the workflow-evidence fixture follow the path; the replay-compat manifest keeps the
historical paths it pins at released tags.

Verified live (Pixel 7 geometry, API 36, --retries 0): control run at main head in CI order
reproduces exactly CI's 4/8; with the fix, `pnpm gate replay-android` 6/6 in both native and
CI order, and `03` leaves Settings on screen (scroll up 3 now touches down at y=240).

* test(scroll): drive the TS and Swift scroll-plan parity vectors from one table (#1820 review)

The two suites hand-mirrored the same vectors and #1820 had to edit both by hand — the drift
class the repo already closes for the tap-point rule via contracts/fixtures/tap-point-policy.json.
The scroll vectors (plus both planner constants, pinned behaviourally on a 1000px axis) now live in
contracts/fixtures/scroll-gesture.json; scroll-gesture.test.ts and RunnerTests+ScrollGesture.swift
iterate it. Verified: vitest 10/10; the four XCTests run on an iOS 26.2 simulator with the unit
flag on (Executed 4 tests, 0 failures).

Also: test/ci/android-workflow-evidence.json says what it guards.

Follow-up for content-safe viewport bounds + discovery order: #1821.
2026-08-18 14:31:54 +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 3651047be8 chore: restore unreleased version (#1805) 2026-08-18 07:50:23 +02:00
Michał Pierzchała 6e33d2c3f7 0.20.10 2026-08-18 00:51:18 +02:00
Michał Pierzchała 0697915b10 0.20.9 2026-08-17 19:01:32 +02:00
Michał Pierzchała 4cfa34bd22 docs: reposition README around mobile app automation for AI agents (#1780)
* docs: reposition README around mobile app automation for AI agents

Lead the README with the category (mobile app automation, testing, and
verification for AI coding agents) and the three surfaces (CLI, MCP
server, Node.js API) so humans and search engines can classify it, then
keep verification and evidence as the differentiator.

- Move per-platform transport/caveat sentences (HarmonyOS HDC/uitest,
  Vega VVD-only) out of the intro into "How it works" and add an inline
  guard comment plus an AGENTS.md rule so new platforms only add a name
  to the intro list.
- Add MCP and Node.js quick starts next to the CLI walkthrough.
- Add a works-with/proof line, a "What to ask your agent" prompt list,
  a product-ladder sentence, and two AEO-shaped FAQ entries.
- Point the cloud/remote row at the remote proxy and device clouds docs.
- Align npm, MCP registry, and docs-site descriptions with the same
  category phrasing.
- Remove em dashes and evaluative filler per the humanizer skill.

* docs: tighten README hero, link proof points, move install above capabilities

* docs: add mobile-MCP FAQ distinction, sharpen hero support line, drop coming-soon

* docs: make the build-on-top audience explicit in README

* docs: trim README repetition and signposting

* docs: close the session in finally in the README Node.js snippet
2026-08-17 17:54:17 +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 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 167f93ba8c 0.20.8 2026-08-11 11:57:42 +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 1b2e786128 refactor: move screen recording onto platform runtime (#1724) 2026-08-11 10:24:57 +02:00
Michał Pierzchała 700d85ec54 0.20.7 2026-08-10 21:24:34 +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