Commit Graph

188 Commits

Author SHA1 Message Date
Michał Pierzchała 5e4a08f9a9 docs: define composable recorded fragments (#2018)
* docs: define composable recorded fragments

* docs: guard composed fragment artifacts
2026-08-25 09:25:07 +02:00
Michał Pierzchała 5b6feafe92 Extract snapshot policy from daemon to host-side facet (#1983) (#2014)
* refactor(snapshot): give the Wave 4 policies neutral host seams (#1983)

#2005 established the presentation ownership boundary and moved the iOS
presentation policies out of `src/daemon/`. It left the three remaining Wave 4
policies behind their existing daemon adapters. This closes that gap, so
`src/snapshot/` owns host-side snapshot policy generally rather than
presentation alone.

Freshness recovery: the window vocabulary, the Android staleness classification
and its thresholds, and the retry loop move to `src/snapshot/snapshot-freshness/`.
The loop is parameterized by a classifier and a retry schedule, so how long a
backend may lag behind a real transition is a policy input rather than a
constant the loop owns. `src/daemon/session-snapshot-freshness.ts` keeps only
what needs a session — reading and retiring the window on store-owned
`SessionState`, and choosing the comparison baseline from snapshot lineage — and
remains the declared R7 owner of `androidSnapshotFreshness`. The two call sites
#1739 named as the Wave 5 blockers, `selector-capture-runtime.ts` and
`deferred-interaction-outcome.ts`, now reach freshness through the seam.

Timeout evidence: whether a failure is the accessibility-timeout shape becomes a
policy in `src/snapshot/snapshot-timeout-policy.ts`. The published
`details.androidSnapshotTimeoutScreenshot` payload becomes vocabulary in
`@agent-device/contracts/snapshot-timeout-evidence`, built through constructors
so an assembly site cannot publish a fifth, undeclared arm. It gets its own
subpath rather than riding the shared capture facade, which keeps it out of the
CLI cold-start closure. Typed details, diagnostics and screenshot evidence are
unchanged.

Screenshot-overlay policy: which Android nodes earn an overlay ref, and what
rectangle an overlay covers, move to `src/snapshot/screenshot-overlay/`. The
daemon keeps approved artifact and ref assembly only — ranking, projection to
screenshot pixels, drawing and PNG IO.

The boundary test generalizes from the presentation subtree to the whole facet:
nothing under `src/snapshot/` may import `src/daemon/`. It gains a positive
control, because a filter that stopped matching would look identical to a
boundary being obeyed.

The residual call sites #1983 also named are audited and deliberately left in
place. `direct-ios-selector.ts` carries no presentation policy; its two pure
exports are selector derivation and ADR 0011 delegation-on-error, whose owner
would be the selector pipeline governed by R19, not this facet. ADR 0004 records
the finding so it does not have to be re-derived.

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

* refactor(snapshot): address adversarial review of the Wave 4 seams

Three findings from an adversarial pass over bc95d7f, all in the new seams.

`SnapshotFreshnessRetrySchedule.deadlineMs` was an absolute epoch instant named
almost identically to the duration constant `ANDROID_FRESHNESS_RETRY_DEADLINE_MS`
that feeds it. A backend binding the loop with the duration instead of
`markedAt + duration` type-checked, drove `remainingMs` hugely negative, and
silently ran zero retries with no annotation. Renamed to `retryUntilMs` — the
pre-refactor local's name — and the doc now says which one it is. The recovery
loop also gains direct tests it never had: the trustworthy, recovered and
still-suspicious paths, plus an already-expired deadline that pins the budget to
the action rather than to whenever the first capture returned, which is the shape
the mis-binding would have taken.

Two stale doc references from earlier drafts of the same commit: the timeout
assembly claimed its evidence shape lives in `@agent-device/contracts/capture`,
which is where it deliberately does NOT live — following that comment would
re-home the type into the shared facade and reintroduce the cold-start closure
cost the dedicated subpath exists to avoid. And the freshness window doc cited
`SnapshotFreshnessPolicy`, a type removed before commit for being unused; the
real seam is the loop's `classify` callback.

No production behavior change.

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

* refactor(snapshot): key timeout evidence on a typed reason, budget retries by duration

Addresses the three review findings on the Wave 4 facet work.

1. The recovery loop accepted an absolute `retryUntilMs`, and its own comment
   admitted that passing a duration type-checks and silently disables retries.
   Documenting a footgun is not removing one. The schedule is now a duration
   budget and the loop derives the deadline from the window's `markedAt`
   itself, so there is no absolute instant a caller can get wrong. Two tests
   pin the invariant: a budget already spent before the loop starts runs the
   capture once, and the same budget retries or not depending only on how old
   the window is — a loop measuring from its own start would return the same
   count for both.

2. The timeout policy recognized failures from hint prose and helper message
   text. That is a message shape standing in for a decision, and extracting it
   into a named facet made it worse by promoting the sniffing to declared
   policy. The Android platform boundary now decides once and publishes the
   typed reason `accessibility-timeout`, joining the existing
   `ANDROID_CONTENT_RECOVERY_REASONS` taxonomy in the contract that already
   exists to stop producers and consumers growing separate ones. The facet
   reads that reason. The hint is derived from it rather than decided
   alongside it, so rewording prose can no longer change what a reader
   concludes. Coverage now runs producer to consumer: the platform tests assert
   that both timeout shapes publish the reason, that an ordinary helper failure
   does not, and that the real policy recognizes exactly what the real producer
   emits — the message-sniffing approvals are gone.

3. `SnapshotTimeoutEvidence` still permitted `annotated: true` with zero refs.
   The annotated arm now carries a non-empty tuple, so the contradiction is
   unconstructible rather than merely unconstructed, with a `@ts-expect-error`
   guard that fails the build if it ever becomes valid again.

The timeout tests moved out of `snapshot.test.ts` into a cohesive
`snapshot-capture-failure-reason.test.ts` rather than growing a file already
over the size tripwire; its pin ratchets down 1495 -> 1445.

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

* refactor(snapshot): decide the capture-failure reason from machine values only

Addresses the two remaining typed-policy blockers on #2014.

P1. The previous commit moved the message sniff rather than removing it:
`androidCaptureFailureReasonOf` still ran `/timed out/i` over helper and
wrapper prose, and a regex over the wrapper message for exit 137. A producer
sniffing prose is the same defect as a consumer sniffing prose, one layer down.

The decision now happens at the deepest boundary that holds the evidence, from
machine-defined values only. `snapshot-capture-failure-reason.ts` maps the
helper's structured `errorType` field by exact equality against
`java.util.concurrent.TimeoutException` — the same constant
`isUiAutomationConnectionTimeoutResponse` already compares — and the SIGKILL
exit code 137, which the fallback constructor knows structurally instead of
re-deriving from the message it just wrote. The helper-result,
session-protocol, and killed-instrumentation constructors attach the reason;
every layer above rewraps it. Both regexes are deleted, and the only
`TimeoutException` string left on the path is that constant.

This tightens behavior deliberately: a helper reporting ok=false with
timeout-looking prose but some other `errorType` is no longer classified as a
timeout. Both directions are proved end to end against the real producer —
four rewordings of the helper message (including empty) keep the typed value,
and three timeout-looking messages under non-timeout error types produce no
value and are not recognized by the real policy.

P2. The evidence union stored `overlayRefCount` beside the refs, so
`{annotated: true, count: 0, refs: [ref]}` and arbitrary mismatches stayed
assignable. No arm stores a count now — it is derived from `overlayRefs`, the
one source of truth — and the arms that carry no refs have nothing to count,
which `overlayRefsAnnotated: false` already states. Two type regressions guard
it: the empty-annotated contradiction, and the reintroduction of a stored
count, both as `@ts-expect-error` so the build fails if either becomes valid.

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

* refactor(snapshot): retire the duplicate timeout classifier on the session path

`isUiAutomationConnectionTimeoutResponse` compared `helper.errorType` to
`java.util.concurrent.TimeoutException` on its own, so the session fallback
diagnostic decided "was this a UiAutomation timeout" a second time. I cited it
as precedent for the constant in the previous round without noticing that
leaving it standing is the drift it was cited against: one taxonomy, two
deciders. The session protocol already publishes the typed reason on exactly
these errors, so the diagnostic now reads it.

The regression is proved rather than assumed: with the protocol's
`androidCaptureFailureReason` attachment removed, the new session-path test
fails; with it restored, it passes. It rides the existing
`ui-automation-timeout` fixture, so it exercises the real socket response
shape rather than a hand-built error.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 07:57:09 +02:00
Michał Pierzchała 775eddd749 feat: session-scoped echo protection for parameterized recorded inputs (#2013)
* feat: session-scoped echo protection for parameterized recorded inputs

Extends ADR 0017's fill-step-scoped guarantee to the whole recording
session (#1398). After #1349, a later read-only action (`wait`, `is`,
`get`) can independently observe and record an app-rendered echo of an
already-parameterized `fill --record-as` value in its own result or
target-v1 identity evidence, re-leaking the literal even though the
originating fill was protected.

- SessionState gains a small, ephemeral, never-serialized
  literal->placeholder registry populated only from explicit
  `--record-as` pairs, owned by session-action-recorder.ts.
- Result/event payload fields get content-aware substring redaction
  (reusing the fill boundary's recursive scrub) for every literal
  registered so far in the session, longest-literal-first.
- target-v1/targets-v1 identity evidence is never silently
  text-substituted while still claiming a trustworthy identity (replay
  compares against the live tree, which re-renders the real value).
  A landmark-mode (wait) echo is dropped to no annotation, exactly like
  #1349's existing identity-empty case, so an echoing landmark can no
  longer serve as an ADR 0016 destination guard. Action-mode evidence
  (get/is/mutating actions) redacts the label and downgrades
  verification to "unverifiable" instead, since ADR 0012/0016 forbid
  dropping required identity evidence.
- Amends ADR 0017 (new mechanism), ADR 0012 (#1349/writer-invariant
  cross-references), and ADR 0016 (destination guard cross-reference).

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

* fix: placeholder-safe single-pass multi-literal redaction

Addresses review feedback on #2013: sequential single-literal
replacement (register somethinglong -> ${ABC}, then ABC -> ${OTHER})
could rewrite a placeholder produced by an earlier pass, corrupting it
to ${${OTHER}}.

Replaces the per-pair sequential loop with one placeholder-safe
left-to-right multi-literal pass (parameterizeAgainstLiteralMap): it
never re-scans text it has already emitted, so no literal can be
matched inside another pair's placeholder token in either direction.
A registered literal is matched before checking for an existing
placeholder token, so a value that itself happens to look like
${SOMETHING} is still redacted correctly. The scan uses a sticky regex
instead of slicing per character, and literal pairs are sorted once
per payload/evidence walk instead of once per string leaf.

parameterizeRecordedFillPayload/parameterizeBackendOutput are
generalized to take injected leaf-transform/carries callbacks so the
single-pair fill-boundary path (with its existing whitespace-collapse
behavior) and the new multi-pair session-wide path share one
structural traversal.

Adds regression coverage for both result payloads and action-mode
target evidence, plus the placeholder-shaped-literal edge case.

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

* fix: unexport parameterizeAgainstLiteralMap (CI: fallow dead-code gate)

Only used internally within this file (by parameterizeRecordedResultEcho
and parameterizeTargetEvidenceEcho); the export had no consumer outside
the module, which the fallow audit correctly flags as dead code.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 21:24:02 +02:00
Michał Pierzchała 50f460cce4 refactor(snapshot): establish presentation ownership boundary (#2005)
* refactor(snapshot): establish presentation ownership boundary

* docs: keep context glossary within budget

* fix(snapshot): address presentation boundary review

* test(snapshot): ratchet eager closure budgets
2026-08-24 20:36:33 +02:00
Michał Pierzchała 021fe2aa1d feat(maestro): support assertTrue phase 1 - literal/${VAR} truthiness (#1295) (#2010)
* feat(maestro): support assertTrue phase 1 - literal/${VAR} truthiness (#1295)

Adds the assertTrue command, scoped to literal values and bare ${VAR}
lookups per the #1292 lookup-only decision; JS expressions keep
failing loud at parse time with a runScript hint. Truthiness on a
looked-up value is evaluated against a pinned falsy-string table
("", "false", "0", "null", "undefined") since flow config/env/
runScript-output values are always stored as strings, rather than
native JS truthiness (which would treat "false" as truthy).

Wires assertTrue through the parser, interpreter, optional/warning
composition, and the layer-1 conformance oracle, narrowing the
067_assertTrue_pass divergence to the JS-expression case and removing
the now-satisfied 076_optional_assertion entry. Also materializes
scrollUntilVisible's default direction in the conformance canonical
projection, a latent gap only exposed once 076 could fully compare.

* fix(maestro): correct assertTrue truthiness claim in CLI help/docs

The support-matrix text said assertTrue is "evaluated with JS
truthiness", but the engine actually uses a pinned falsy-string table
("", "false", "0", "null", "undefined") since looked-up values always
arrive as strings — native JS truthiness would treat "false" as
truthy. Spell out the actual rule instead of the misleading claim.

* fix(maestro): fix oxfmt quote-style violation in expected-divergence.ts

CI's format gate failed on a single-quoted string containing an
apostrophe; oxfmt prefers double quotes there.

* fix(maestro): bump eager-closure-budget pin for the new truthiness module

engine-truthiness.ts is a genuinely new module on the core interpreter
path (assertTrue is dispatched unconditionally by
replay-plan-step-execution.ts), so packages/maestro/src/index.ts now
eagerly evaluates 105 modules instead of 104 — a deliberate growth,
not a laziness regression.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 20:15:26 +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 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 d713988c5a docs(agents): simplify testing and pull-request guidance wording (#1997)
* refactor(lint): replace the facade import scan with a lint rule

The surviving half of `contracts-entry-closure.test.ts` walked ~490 candidate
files and parsed each one to assert that nothing value-imports the two wide
contracts facades. `eslint/no-restricted-imports` already states exactly that,
and `allowTypeImports` already draws the one distinction that made the walker
seem necessary: `import type` is erased, so it stays legal.

Verified rather than assumed, because the override semantics are not additive:
a same-rule override REPLACES the parent, so a top-level rule would have been
silently dropped for `src/**`, and the existing `"off"` entry for `exec.ts` and
the test tree would have exempted the files that carried most of the cost
#1959 removed. So the paths are added per zone, and the blanket `"off"` becomes
a facade-only config that keeps the `node:child_process` exemption it existed
for.

Planted red in all three zones — `src/core/capabilities.ts`, a `src/__tests__`
file, and `packages/capture-kit/src` — each flagged, while a type-only import in
the same probe file was not. A first probe read as a pass because the sed that
built it produced a type-only import; the zone was re-probed with a real value
import rather than trusting the green.

Misconfiguration fails loudly, which is why this is safe to rely on: a typo'd
rule name makes oxlint exit 1 with "Rule not found in plugin", not pass silently
(the failure mode #1976 records for the `rg` assertions).

What a linter cannot replace, and stays: the eager-closure budgets. Those are a
transitive-weight property — a module already imported grows an import, and the
cost arrives without any single file's import list changing. Per-file rules
cannot see that, and `no-restricted-imports` can only ban specifiers named in
advance, which is precisely what #1950/#1956/#1959 could not have named.

* docs(agents): simplify testing and pull-request guidance wording

testing.md sat 15 bytes under the 10k per-doc check:agent-guidance cap.
Rewrite both docs in shorter, plainer sentences without dropping any
fact, threshold, or identifier (backtick-identifier sets verified
unchanged against the previous revision). Also fix testing.md's gate
catalog sentence being separated from its code block and the missing
blank line before pull-requests.md's Reviewing section.
2026-08-24 16:40:53 +02:00
Michał Pierzchała c4b1a6131e dx(test): opt-in worker-count override for solo local vitest runs (#1964)
* dx(test): opt-in worker-count override for solo local vitest runs

resolveVitestMaxWorkers() caps local runs at 2 workers so parallel
worktrees and spawn-heavy tests keep headroom, but a solo run that owns
the machine pays 6x on a 12-core host for no benefit.

Add AGENT_DEVICE_VITEST_MAX_WORKERS to opt in to a higher cap. It is
clamped to os.cpus().length so a runaway value can't oversubscribe the
host, and it is a no-op in CI (CI already derives its own worker count).
A missing, blank, non-numeric, non-integer, or non-positive value falls
through to the existing default cap rather than throwing. Default
(unset) behavior is unchanged.

Closes #1962

* docs: tighten the worker-override note to fit the agent-guidance budget

docs/agents/testing.md sits at a 10,000-byte per-file ceiling enforced by
check:agent-guidance, and the first phrasing pushed it to 10,065. Restate
the override in one tighter bullet that leads with the "solo run only"
caveat, which is the constraint a reader most needs.

* fix(test): clamp the worker override with os.availableParallelism()

Node documents cpus().length as unfit for sizing application parallelism:
it ignores CPU affinity and cgroup limits, so it can report a pool wider
than the process may actually use. Clamping against it would inflate the
very ceiling this override's safety clamp exists to enforce.

availableParallelism() honors those constraints, so the clamp now means
what it claims on constrained hosts. Test updated to match.

* test: keep the resolver cases in the already-included setup test

Review feedback: a new test file beside the resolver, plus its entry in
vitest.config.ts's unit-core include list, is a change to test discovery
that the mutation lane's `vitest related` graph reads. Fold the override
cases into src/__tests__/hermetic-env-setup.test.ts, which is already in
the unit suite and already imports the resolver, and drop the config edit
entirely so this PR no longer touches test discovery at all.

Same six assertions, no coverage lost.
2026-08-24 10:26:25 +02:00
Michał Pierzchała 7f3e355426 fix(ios): preserve regular snapshot depth through structural wrappers (#1947)
* fix(ios): complete regular snapshot depth frontier

* fix(ios): align depth frontier with visibility fold

* fix(ios): exercise regular depth frontier in CI

* fix(ios): cover visible-depth frontier through public snapshot

* fix(ios): tolerate absent deep-link confirmation

* test(ios): expose visible-depth fixture hierarchy

* test(ios): wait for visible-depth fixture subtree

* fix(ios): keep visible-depth fixture minimal

* fix(ios): update snapshot hint fixtures

* test(ios): avoid fixture label aggregation

* test(ios): match fixture raw hierarchy

* test(ios): prove visible-depth raw ancestry

* test(ios): align depth smoke with AX hierarchy
2026-08-22 13:53:39 +02:00
Michał Pierzchała cb65d6ca1f refactor(tests): replace the test-utils barrel with direct module imports (#1956)
* refactor(tests): replace the test-utils barrel with direct module imports

The barrel re-exported 13 modules, so every importer evaluated all of them
(store-factory alone drags 16 daemon session-store files; property-arbitraries
drags fast-check). Importing the backing modules directly cuts the unit
suite's aggregate eager module evaluations from 153,401 to 144,344 (-5.9%),
measured with the eager-import-closure walker. Deleting the barrel makes the
tax unrepresentable instead of pinning it with a guard test.

* docs(testing): point fixture guidance at the test-utils modules, not the deleted barrel

* test: extract replay session fixture
2026-08-22 12:21:35 +02:00
Michał Pierzchała 991c08561b fix(ios): enforce regular snapshot clip invariant (#1946)
* fix(ios): enforce regular snapshot clip invariant

* fix(ios): restore typed snapshot failure construction

* fix(ios): linearize snapshot clip validation

* fix(ios): propagate snapshot presentation errors

* fix(snapshot): clarify presentation failure recovery
2026-08-22 12:15:40 +02:00
Michał Pierzchała af96c6608d feat(ios): publish effective snapshot geometry (#1931) 2026-08-21 11:27:04 +02:00
Michał Pierzchała 73db7be2ff feat(ios): move the regular-projection clip fold into snapshot presentation (#1797) (#1929)
* feat(ios): move the regular-projection clip fold into snapshot presentation

Both iOS snapshot backends carried their own copy of the visibility fold: the
tree walker and the private-AX serializer each computed viewport-and-scroll-clip
intersection, ancestor projection, hidden-content hints, and collapsed depth
during acquisition. Hand-synchronized copies of that interpretation are what
produced the scroll-overflow leak class (#1784), and C1 (fact-availability
neutrality) could not hold while acquisition decided what a screen shows.

Acquisition backends are now fact serializers: every traversed node is emitted
at raw traversal depth with its reported frame, and SnapshotAcquisition carries
the viewport. presentRegular runs the one clip fold for every backend --
viewport ∩ scroll clip, the ancestor cursor (an out-of-clip Cell or scroll
container hides its clamped descendants), the sub-pixel decoration rule,
scroll hints booked onto anchors, reparenting with collapsed depth -- and
narrows the emitted hittable to the clip: nothing outside its clip, and nothing
without geometry, is ever hittable, whatever the backend reported. Platform
differences are a SnapshotFoldPolicy input to the shared algorithm (iOS
cursor-projected; macOS/tvOS plain viewport), never a backend exception.

The private-AX backend collapses to ONE serializer for both projections, and
the flat filter-decision family dies with the acquisition gates it fed.

Three intentional edge deltas, each toward one backend-neutral rule: sub-pixel
content-free decorations now drop on every backend (was private-AX only);
labeled offscreen Application/Window carriers survive on every backend (was
tree only), never hittable; query-sweep regular without -i is viewport-folded.
Declared acquisition residues: the traversal-depth budget cut, the sweep's
frameless-element drop, the private-AX bridge's device-side cap.

Refs #1797 (migration step 3, clip-fold delta).

* refactor(ios): isolate snapshot visibility fold
2026-08-21 11:27:04 +02:00
Michał Pierzchała 6911274952 docs: record visionOS/watchOS support decision (#1918)
* docs: record visionOS and watchOS support boundary

* docs: clarify visionOS deployment boundary
2026-08-21 07:55:30 +02:00
Michał Pierzchała 4137e4275e feat(ios): split raw and regular snapshot projections behind one capture hint (#1926)
The private-AX backend interpreted `--raw` as the regular projection: it folded
the viewport and scroll clips and dropped sub-pixel decorations before returning,
so a raw capture that recovered onto it answered with viewport-pruned nodes
labeled raw (#1797 D4). Nothing related the two copies of that decision.

Presentation now exposes the two projections it always implied. `presentRegular`
folds visibility, eligibility, scope and scroll hints; `presentRaw` is the
acquired tree, normalized, narrowed only by a scope or depth the request asked
for. Acquisition reads one derived `CaptureHint` instead of the request itself,
so what a capture may skip is stated once, beside the reason skipping it keeps
the projection complete: scope and its relative depth never narrow, raw depth
does (raw depth is traversal depth), and the raw projection never carries
`interactiveOnly` — `--raw -i` is the acquired tree.

Two structural rules replace the hand-synchronized ones. The raw plan is derived
from `SnapshotBackendKind.supportsRawProjection`, so the query sweep — an
interactive element query with no hierarchy to return — cannot be planned for a
raw request. And presentation compares the requested projection with the hint
the acquisition was captured under, dropping that tier with a structured
`IOS_SNAPSHOT_PROJECTION_MISMATCH` failure rather than presenting it under the
requested label.

Declared residue: a regular `--depth` request still cuts the traversal at that
depth while regular presentation emits collapsed depth, so a node that would
present within the limit can be dropped. The cut is what keeps `--depth 1`
probes cheap; making it complete is the open visible-depth frontier obligation.

Refs #1797 (migration step 3, raw-projection delta).
2026-08-20 21:44:48 +02:00
Michał Pierzchała aed00aef42 docs: capture gesture verification lessons (#1913) 2026-08-20 19:49:55 +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 dd2a18ed4d perf(check-affected): stop running coverage locally, CI stays authoritative (#1908)
* perf(check-affected): stop running coverage locally, CI stays authoritative

The `coverage` gate re-ran the affected Vitest suite under instrumentation
on every `check:affected --run`, adding real overhead for signal the
dedicated `Coverage` CI job already enforces on every PR. Mark it
GitHub-authoritative and let `vitest-related`/`unit`/`provider-integration`
run locally on their own instead of being folded into a coverage pass.

Also removes the now-dead dedupe machinery in run.ts that existed only to
support the local coverage-instrumented run.

* fix: keep affected tests fast and bounded
2026-08-20 16:44:48 +02:00
Michał Pierzchała 17bdca76cc refactor: migrate wait to request-bound runtime (#1875)
* refactor: migrate wait to request-bound runtime

* fix: preserve native selector wait observation

* fix: classify wait observations as conditional

* refactor: compact conditional runtime declarations

* fix: isolate selector runtime intents
2026-08-20 15:59:38 +02:00
Michał Pierzchała f9d8618318 fix(maestro): make iOS presentation correspondence authoritative (#1899)
* fix(maestro): make iOS presentation correspondence authoritative

* refactor(maestro): simplify presentation target resolution
2026-08-20 15:06:58 +02:00
Michał Pierzchała 9ce1ef7587 feat(snapshot): move scope into presentation (#1855)
* feat(snapshot): move scope into presentation

Use one preorder label/identifier/value policy across Swift and TypeScript, keep scoped iOS acquisition conservative, and remove the daemon's second scope pass.

Non-vacuity: label-only matching failed identifier/value parity fixtures; Android pass-through failed its boundary test; disconnecting Swift applyScope produced eight scope/depth/projection failures.

* fix(snapshot): select scopes with presented content

* docs(snapshot): describe presentation-owned scope

* docs(snapshot): record contribution-aware scope

* refactor(snapshot): drop unrelated provider churn

* fix(snapshot): accept healthy empty scoped capture

* refactor(snapshot): isolate empty-scope admission

* fix(snapshot): align scope ownership across runtimes

* test(snapshot): pin post-wire scope owner

* test(snapshot): retain find test shrink
2026-08-20 10:30:18 +02:00
Michał Pierzchała 80b4769230 test(fuzz): structured CLI/Maestro generators that reach command validation and assert error codes (#1781 B2) (#1866)
* test(fuzz): structured CLI/Maestro generators that reach command validation and assert error codes (#1781 B2)

* test(fuzz): pin the rediscovered #1433 excess-positional case and keep numeric flag samples inside their range

* style: apply oxfmt to the new fuzz modules

* perf(fuzz): derive the CLI validation surface lazily so unrelated harness paths keep their startup

* test(fuzz): resolve validation generators in the run path so corpus replay keeps its small module graph

* test(fuzz): weight the CLI budget toward command validation, pin the finite classes as seeds, guard lazy surface derivation

* docs(testing): describe the validation lane's layer split, seed-pinned classes, and PR-time gates

* refactor(fuzz): split the validation generator into CLI and Maestro modules, mirrored in tests

* refactor(fuzz): collapse the flag-shaped mutation classes and seed literals, derive class coverage from declarations

* fix(fuzz): hash every case-generation module in configHash, guarded by an import-closure test

* test(fuzz): assert CLI command and flag-key coverage against the registry, and close the six gaps it found
2026-08-20 08:00:08 +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 ef2094c9d7 docs: keep size review in CI and local feedback fast (#1842)
* feat(size): measure a base ref in one command (pnpm size --base <ref>)

The Size workflow already compares base and PR builds; locally that needed a
manual checkout, install, build, --json, and --compare dance, so budgets were
negotiated late. --base <ref> does the workflow's recipe in a detached worktree
under .tmp/size-base/<sha> (kept for reuse, other bases pruned) and compares
against it: first run ~1-2 min, later runs against the same base ~3s.

Documents the local caveat: npm tarball/unpacked rows compare a fresh base
against a working tree that may carry locally built helper artifacts.

* feat(tooling): pnpm pr:evidence — one paste-ready, SHA-stamped evidence block for PR bodies

Composes what the repo already measures instead of hand-transcribing it after
every rebase: exact merge-base and head, changed-file areas, the affected
selector's plan (local vs GitHub-authoritative, fail-open summarized), the
layering guard verdict, depgraph counts with a real delta against the base (a
throwaway git worktree, no install — the script analyzes its cwd while its
imports resolve from this checkout), and, behind flags, the changed-line
coverage table and pnpm size --base. It claims nothing about CI: the last line
links the head's checks. ~20s default tier.

The pure model (grouping, report parsing, rendering) has node:test coverage
registered as the pr-evidence-model gate, run in the Affected-check Selector
job next to the selector it reads.

* fix(tooling): pr:evidence measures pristine head/base worktrees from an os.tmpdir scratch; size --base gets a per-SHA lock, completion stamp, and non-destructive eviction

Review (three P1s):
- pr:evidence created its scratch under an untracked .tmp/ that a fresh
  checkout lacks (ENOENT). Scratch now lives under os.tmpdir(), which exists
  by construction; a real entrypoint regression runs the whole pipeline with
  --base HEAD (no origin/main needed) and asserts JSON shape plus cleanup of
  both worktrees and the scratch.
- Untracked or uncommitted production files could move the layering/depgraph
  numbers the block labels as HEAD's. Head is now measured from a pristine
  worktree of the head commit exactly like base, and the affected plan takes
  the head SHA (the literal HEAD folds the working tree in). The dirty flag
  now counts untracked files and says they are not in the block.
- size --base force-pruned other cached bases without locking and trusted a
  dist/src that could be half-built. Per-SHA .lock (pid, O_EXCL) held from
  before the worktree exists until the base report is read; a live lock on
  the same base fails fast, a stale one is replaced; eviction skips worktrees
  whose lock owner is alive; dist/.size-base-complete marks a finished build.
  Orchestration tests run the real script against a throwaway git repo with
  pnpm/npm shimmed on PATH (build once, reuse, live lock, stale lock,
  interrupted build, guarded vs idle eviction). Also fixes the /tmp →
  /private/tmp realpath mismatch those tests surfaced (git lists worktrees by
  real path, so the registration check removed a live worktree).

* fix(tooling): symlink-identity locks with compare-then-unlink; evict under the victim's lock; pr:evidence registers worktrees on add and cleans up exhaustively

Review (three P1s):
- Lock creation/takeover races: the lock is now a symlink whose target is
  the owner identity (pid:nonce), created with its identity in one syscall
  (no empty-file window), taken over only by compare-then-unlink on the exact
  identity judged stale, and verified after creation; release unlinks only a
  link that still names this run. Real overlapping-process tests: two runs on
  one base (exactly one builds, the other fails fast), and a takeover race
  against a simulated other taker across delays straddling the acquire window
  (a live lock is never unlinked, both never proceed).
- Cross-base eviction: a victim is removed only while holding its own lock,
  acquired through the same path, so a run wanting it after the check finds
  it locked rather than half-removed; a live-locked victim is skipped.
- pr:evidence worktrees: withWorktrees registers each worktree the moment its
  add succeeds and sweeps every resource on the way out, collecting failures
  instead of stopping at the first; planted reds for both (second add fails →
  first removed; removal of the middle one throws → the others still go).

* test(size): serialize the size --base orchestration file with the other real spawners

Caught running the full unit suite on the rebased branch: the file passed in
isolation but intermittently failed under broad file parallelism, where it took
14s versus ~5.5s alone. It spawns node scripts/size-report.mjs per case, which
spawns git and the shimmed package managers under it — the SUBPROCESS_STUB_TESTS
class exactly (starved spawns surface as a vitest test timeout instead of the
orchestration assertion the case is about), so it joins that serialized project
with its spawn named at the entry, per docs/agents/testing.md. No rerun layer is
involved: the flake is removed, not retried. Two full-suite runs green after.

* refactor(size): extract the base-cache claim protocol and make stale takeover atomic

Review (P1 + architecture):

Stale-claim removal was compare-then-unlink (readlink then unlink; lstat then
rm for a stray file), so another taker could replace the observed entry with
its live claim between the two syscalls and this run would delete the
replacement. Removal now happens only while holding the entry's takeover mutex
— an atomically created directory — and re-verifies the claim inside it. A
replacement can appear only by creating one on a free path (the abandoned
claim occupies it until the unlink) or by another takeover (needs the mutex),
so removal cannot delete a replacement. A mutex leaked by a process killed
inside its sub-millisecond critical section is reclaimed by age, and even a
wrong reclamation is contained: both takers re-verify inside, and the winner
is still decided by the atomic symlink() that follows.

The protocol moves out of size-report.mjs into scripts/size-base-cache.mjs
(AGENTS.md: extract past 500 LOC) — 719 → 536, with the entry lifecycle
(claim → evict others → ensure worktree → build if unstamped → measure →
release) owned by the module behind withPreparedBaseWorktree. Mirrored tests
in scripts/__tests__/size-base-cache.test.ts plant every dangerous
interleaving directly on the filesystem: replacement-after-observation, a
takeover held by another run, age reclamation, release-after-retarget, and a
stray non-symlink. They need no subprocess and run in 9ms, so the raced
single-process case was dropped from the orchestration file, which keeps only
what real processes can show. Planted red: removing the mutex makes the
contended case delete the claim it must not touch.

* ci(size): preserve the reporter's whole module graph, and gate that it stays whole

The Size workflow measures the base commit with the PR's reporter, so it
copies the reporter out of the tree before checking the base out. Extracting
size-base-cache.mjs made the reporter a two-file graph while the step still
copied one file, and the base measurement died with ERR_MODULE_NOT_FOUND —
after every deterministic gate had passed, because nothing local reproduces
that copy.

The step now copies the scripts directory, so a further split cannot leave an
import behind, and size-report-preserved-closure.test.ts holds it to the
reporter's real relative-import closure and to running the preserved copy
rather than the checked-out tree. Planted red: restoring the single-file copy
fails both cases, naming scripts/size-base-cache.mjs. Verified by running the
reporter from a copied directory exactly as the workflow does.

* fix(size): the takeover mutex has one holder for life; split report publishing out of the reporter

Review (P1 + architecture):

Age-based reclamation of the takeover mutex reintroduced the split ownership
the mutex exists to prevent: a holder that is merely slow — paused or
SIGSTOPed past any threshold — could have its mutex force-removed and
replaced, putting two takers inside the supposedly exclusive section, where
either could unlink the claim the other had just created; the unconditional
pathname-based release could also delete the replacement mutex. The mutex is
now a symlink naming its holder, created in one syscall, never reclaimed at
any age, and released only by the run that owns it. A mutex leaked by a
process killed inside a three-syscall critical section wedges one cache entry
with the path to clear in the message, rather than silently deleting another
run's live claim. Planted red: restoring age reclamation displaces a
day-old delayed holder, which the new case pins.

Publishing the report to a PR is a separate question from measuring and
formatting it, so it moves to scripts/size-report-comment.mjs with the marker
and retry policy it owns; its existing regression drives it through the real
script unchanged. scripts/size-report.mjs is 386 LOC — under the 500 tripwire
and below the 512 it had on base.

* test: prove delayed size cache holder is preserved

* docs: keep size review in CI
2026-08-19 14:22:52 +02:00
Michał Pierzchała d07b837621 test: classify the runner XCTests — pure decisions to a macOS host lane, simulator semantics gated os(iOS) (#1781 A7) (#1861)
Every declared AgentDeviceRunnerUITests method now belongs to a lane, and
the #if guard is the classification: AGENT_DEVICE_RUNNER_UNIT_TESTS alone
means a pure runner decision (runs on the macOS host on every PR — ci.yml's
existing compile job now executes the bundle it builds), '&& os(iOS)' means
runner/XCTest semantics (simulator lanes only). check:xctest-selection
evaluates the guards per platform, derives each lane's reach, and fails on
a flagged identifier that is undeclared or uncompiled on that lane, on a
declared test no lane reaches (found the two tvOS-only tests, dark since
birth — widened to os(tvOS) || os(macOS)), and on testCommand reaching any
lane. The host and nightly lanes assert executed == derived reach, so a
missing -D flag or a guard that compiles a file out reads red, not as a
smaller green. One duplicate test deleted (sparse-verdict assertions folded
into its twin).
2026-08-19 13:59:45 +02:00
Michał Pierzchała e4c3b420a4 test: refuse foreign-pid signals from unit-test workers (Coverage fork death, #1824) (#1854)
* ci: w3-1824 experiment — trace fork signals and plant pid sentinels in the Coverage job

Temporary instrumentation for #1824. Every vitest fork logs each real
process.kill it sends to a foreign pid (and every kill/pkill it spawns);
the Coverage job parks sentinel processes on the pids the Apple runner
tests fabricate (4141/4242/4343/4444) and reports which of them survive
the run. Reverted before this PR leaves draft.

* test: refuse foreign-pid signals from unit-test workers

A vitest worker may signal only itself and the processes it spawned.
src/__tests__/hermetic-signal-setup.ts records any other process.kill,
answers it with ESRCH (so best-effort kill paths proceed as if the pid
were dead), and fails the sending test by name in afterEach.

The senders this catches today are the Apple runner tests, which
fabricate runner child pids (4242, 4141, 4343, 4444) and mocked the
liveness reads in host-process.ts but not the signal writes:
killRunnerProcessTree delivered real SIGINT/SIGTERM/SIGKILL to those
pids and their process groups — 146 signals per run of
runner-session.test.ts. On the CI runner the sibling vitest forks live
in that pid band, so the Coverage job periodically lost one fork
mid-file with no test attributed (issue #1824, 6 of the last 40 red CI
runs).

The group-signal write moves behind signalProcessGroupBestEffort in
host-process.ts, next to signalPidsBestEffort, so the runner tests mock
the signal seam in the same place they already mock the liveness reads.

Refs #1824

* Revert "ci: w3-1824 experiment — trace fork signals and plant pid sentinels in the Coverage job"

This reverts commit a8a020099c.

* test: refuse spawned kill/pkill writes too, and share the guard with the mutation lane

Review of #1854 found three gaps in the first pass:

- The guard intercepted process.kill only, so the spawned half of the same
  function family was unguarded: runner-disposal spawns `pkill -P <pid>` and
  `pkill -f 'xcodebuild.*AgentDeviceRunner.env.session-...'`, and
  request-router-open.test.ts fired that pattern kill twice per suite run. On a
  developer machine with a live Apple runner, `pnpm test` could reach it. The
  setup file now refuses kill/pkill/killall spawns with ENOENT — which the
  best-effort callers already tolerate — and records them the same way; that
  test stubs the Apple tool seam.
- vitest.mutation.config.ts hard-coded its own setupFiles list, so the Stryker
  lane ran without the guard. SETUP_FILES is now exported from vitest.config.ts
  and imported there, next to the SUBPROCESS_STUB_TESTS import that already
  crossed the same boundary.
- 'processes it spawned' meant direct children only; a grandchild started
  through a shell wrapper was refused with advice that did not fit. The docs and
  the failure message now say direct children and name the remedy.

Synchronous spawns are no longer remembered as own pids: spawnSync and
execFileSync have already exited when they return, so keeping their pids would
license a signal to whatever inherits them next.

Refs #1824

* test: end signal authority at child exit, and guard the promisified execFile path

Re-review of #1854 found two holes in the guard itself, both the class it
exists to close:

- An async child's pid stayed authorized for the worker's lifetime after it
  was reaped. A pid is a claim on a process-table slot, and the kernel reissues
  that slot once it is free, so 'I spawned this pid once' licensed a signal to
  whatever holds it now. Authority now ends on exit/close. Cleanup that signals
  a child must gate on isProcessAlive, which is what the existing cleanup paths
  already do.
- wrapSpawner copied execFile's original util.promisify.custom onto the
  wrapper, and promisify() resolves through that symbol instead of calling the
  function — so every promisified caller got an unguarded execFile, bypassing
  both the kill-binary refusal and child tracking. That path is now wrapped too.

hermetic-signal-setup.test.ts covers both, plus the allowed cases they could
regress into: a reaped child's pid is refused, a promisified execFile cannot
smuggle a pkill, a promisified child is still tracked, a live foreign pid is
refused, and signal 0 stays free. Reverting either fix reds three of them.

Refs #1824
2026-08-19 13:55:16 +02:00
Michał Pierzchała 8c06965d28 fix(daemon): cap events.ndjson with cursor-safe rotation (#1867)
* fix(daemon): cap events.ndjson with cursor-safe rotation

Rotate events.ndjson to events.ndjson.1 once it reaches
AGENT_DEVICE_EVENT_LOG_MAX_BYTES (default 5 MB), keeping one rotated
generation. Cursors stay absolute across rotation through a sidecar
window offset, so a persisted nextCursor still names the same event; a
cursor older than the retained window fails with COMMAND_FAILED and
details.reason EVENT_LOG_CURSOR_EXPIRED instead of returning a wrong
page.

Closes #1788

* fix(daemon): verify the events.ndjson window against the files on disk

Rotation recorded only a dropped-line offset, written after the rename,
so a reader landing in that window mapped every absolute cursor a whole
generation too far (reproduced: 5 of 58 reads returned event 17 for
cursor 9), and a missing rotated file or stale sidecar shifted cursors
permanently and silently.

The sidecar now records each retained generation's first absolute line
index, line count, and first-line digest, and is written before the
rename it describes. The reader identifies each file on disk by digest,
derives its start from the matching record, and checks the recorded line
count and generation contiguity; anything unverifiable raises a typed
EVENT_LOG_WINDOW_UNVERIFIED instead of a guessed offset. A torn snapshot
(rotation landing mid-read from the threadpool) is retried, not
interpreted. A corrupt sidecar fails reads typed and never blocks
appends, and rotation no longer does synchronous whole-file I/O.

* refactor(daemon): split event-log window placement and share one line splitter
2026-08-19 12:53:51 +02:00
Michał Pierzchała f3d5b3d92c refactor(daemon): admit-before-bind as an admitted-plan token; retire the R32 syntax policy (#1841)
* refactor(daemon): admit-before-bind as an identity-keyed admitted-plan token; retire the R32 syntax policy

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

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

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

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

#1836 documented inspectRequiredRuntimeUse with a forward note pointing here;
this PR makes admitRuntimePlan real, so the row now teaches it plus the
identity-keyed unwrap the binder uses, and points at the shared snapshot/diff
owning interface as the model.
2026-08-19 10:46:25 +02:00
Michał Pierzchała b12a3e3cb3 test: pin test files over 1,000 lines at their exact length so they can only shrink (#1843)
* test: pin test files over 1,000 lines at their exact length so they can only shrink

AGENTS.md has said for a while that past 1,000 lines is architecture debt and
tests are not exempt; nothing enforced it, and the second-largest test file
gained 55 lines in the PR before this one. This is the slow-test ratchet's
shape for a reader's context instead of wall clock: the 26 test files over the
tripwire are pinned at their exact length (R9-style equality pin, #1781 A6);
growth fails, shrink fails until the pin is lowered in the same PR, a file
that drops under the line leaves the list, and a new file may not cross it.
One directory walk per unit run, ~250ms; the pin list emptying deletes it.

* test(ratchet): hold giant test files to their merge-base length so pin edits cannot admit growth

Review (P1): the equality pin compared measured lengths only against the pin
map in the same checkout, so growing a file and raising its pin, or adding a
new >1,000-line file with a pin, stayed green. The gate is now history-backed:
every test file over the tripwire may be no longer than at the merge-base with
origin/main (renames followed; new files may not cross the line), and no pin
may exceed its file's base length — one git cat-file --batch spawn, parsed by
bytes because the sizes are bytes. Both bypasses planted red against real git
on a pinned file and on a fresh 1,001-line file with a pin added.

* test(ratchet): a pin on a file at or under the tripwire is itself a finding

Review: a new pin for an unchanged sub-tripwire file (900 pinned at 900)
passed equality and history and grew the map. Pins now exist only for files
over the tripwire — any other pin is red with 'remove it' — which also
subsumes the old shrink-under-the-line message. Planted red in-file and
against real git (a 186-line test pinned at 186). The android snapshot test
pin bootstraps 1636→1660: main grew that file in #1846 before this gate
exists, and history agrees (1660 at the merge-base).
2026-08-18 19:40:34 +02:00
Michał Pierzchała ee13203a16 feat(ios): unify snapshot eligibility (#1850)
Make iOS regular snapshot eligibility one backend-neutral presentation rule.

Acquire tree nodes conservatively, preserve interactive scroll containers, normalize surviving hierarchy, and keep raw membership plus daemon publication policy unchanged. Part of #1797.

- iOS and macOS unit-enabled runner builds
- 2 focused XCTest cases
- 3 production-path publication tests
- live Settings snapshots: 73 regular nodes and 167 raw nodes, both healthy tree captures
2026-08-18 18:57:29 +02:00
Michał Pierzchała 72d421fe36 docs(agents): ADR 0019 unit checklist, owning-seam mock rule, worktree and rebase guidance (#1836)
* docs(agents): ADR 0019 unit checklist, owning-seam mock rule, worktree and rebase guidance

Retro follow-up (item 2). Adds docs/agents/adr-0019-unit.md — the order of
operations for one command unit with the declaration site for each step, the
evidence a unit review must carry, and what 'done' is not — so the pattern
rediscovered during the snapshot unit (#1779) is written down once.

testing.md: mock the seam the code under test consumes (fake inspectFacts /
bindDevice), not the generic dispatchCommand mock; a migrating command moves
its tests off the dispatch mock in the same PR.

AGENTS.md: fresh-worktree preflight (pnpm install + build in the worktree;
layering scan reads tracked files only) and concurrent-agent hygiene (one full
gate per host, verify subagent edits with git -C, one PR per worktree).

pull-requests.md: two readiness claims (published-and-reported vs merge-ready)
and the rebase rule — main has no up-to-date protection; rebase on conflict or
when `check:affected --base <merge-base> --head origin/main` names your surface.

* docs(agents): name the admitted-plan token in the ADR 0019 unit checklist (#1841)

* docs(agents): merge-ready owes live evidence only for changed device-facing paths

* docs(agents): the unit checklist documents the admission API on main; #1841 updates the row when it lands
2026-08-18 18:43:27 +02:00
Michał Pierzchała a70cdee360 refactor(ios): route snapshot backends through presentation (#1848)
## Summary

Route every iOS capture-plan backend through one SnapshotPresentation boundary while preserving each backend's current output semantics.

SnapshotAcquisition now carries nodes and attempt-level facts, PresentationOptions is the stable policy input, and only the presentation module assembles wire-facing nodes. Part of #1797.

Touches 10 files within the existing iOS snapshot module and its architecture vocabulary; scope did not expand beyond the planned command family.

## Validation

- Unit-enabled iOS runner build and focused presentation XCTest passed.
- Removing the custom-action handoff made the focused test fail with exactly two assertions, proving the routing check is non-vacuous; restoring it returned to 1/1 green.
- macOS runner build passed for the shared Swift path.
- XCTest selection and repository formatting checks passed.
2026-08-18 18:24:44 +02:00
Michał Pierzchała 6a8beb653e feat(mcp): compact server instructions in both eras + MCP-only help tool (#1839)
* feat(mcp): compact server instructions in both eras + MCP-only help tool (#1833)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Regression uses the weekly argument shape (`--expect-shards 10`, one shard
present) and asserts the reporting kernel's row reaches stdout while the run
still fails.
2026-08-18 17:47:29 +02:00
Michał Pierzchała 8300fa131e refactor(ios): establish snapshot presentation seam (#1845)
Introduce RawAXNode and PresentedNode so acquisition backends can no longer construct the wire-facing snapshot shape directly. Preserve current output while #1797 moves semantics behind the seam.

Non-vacuity: setting PresentedNode.label to nil made testSnapshotPresentationPreservesCurrentWireShape execute once and fail on the missing label field; restoring the production mapping made the same focused XCTest pass.
2026-08-18 17:39:26 +02:00
Michał Pierzchała ef6ec2995b chore(layering): document R12/R18/R19, retire R8, make R9 shrink mandatory (#1781 A6) (#1825)
* chore(layering): document R12/R18/R19, retire R8, make R9 shrink mandatory (#1781 A6)

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

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

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

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

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

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

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

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

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

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

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

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

* ci(ios): tighten the nightly timeout to the measured suite duration
2026-08-18 12:00:43 +02:00
Michał Pierzchała ccf64f6797 ci: move parked device replay suites to a dispatch-only workflow (#1781 A1) (#1794)
* ci: move parked device replay suites to a dispatch-only workflow (#1781 A1)

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

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

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

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

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

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

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

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

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

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

* test: keep appstate capability fixture below complexity limit

* test: cover appstate required readiness fact

* fix: align appstate facts with boot readiness

* fix: keep appstate use declaration minimal

* fix: close appstate parity and ownership gaps

* docs: record final appstate size accounting

* fix: merge neutral runtime imports

* docs: align final appstate size totals

* fix: remove stale runtime dependency edges

* docs: correct appstate size accounting

* refactor: keep runtime-use factory internal

* docs: itemize runtime-use relocation

* fix: move appstate queries into runtime packages

* refactor: retire root foreground query paths

* refactor: share Android foreground parser ownership

* fix: preserve Android appstate parser precedence

* docs: keep appstate evidence in review artifacts

* fix: keep appstate runtime loading lazy

* fix: fail closed for stale limrun appstate

* fix: preserve limrun recovery and abort appstate

* fix: narrow limrun exact-owner recovery

* fix: allocate appstate cutover rule

* fix: reconcile appstate with merged main

* style: format harmony runtime test

* fix: allocate appstate rule id

* fix: allocate appstate layering rule

* fix: remove stale app command admissions

* fix: close appstate layering regressions

* fix: align Harmony capability parity with runtime facts

* test: cover limrun recovery-only readiness

* fix: keep Limrun recovery binding app-log only

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Part of #1429.

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

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

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

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

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

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

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

Six review findings plus the mutation blocker.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Sorting longest specifier first makes the most specific alias win:

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

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

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

* refactor: derive gate ownership from workflow structure

* fix: run gates without optional arguments

* fix: resolve mutation workspace subpaths exactly

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 16:02:18 +02:00
Michał Pierzchała 8f98d23f14 refactor(layering): give each colliding rule id its own number (#1750)
* refactor(layering): give each colliding rule id its own number

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

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

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

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

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

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

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

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

Both entries are therefore deleted in the change that removes the collisions,
leaving the empty list that admits nothing. The namespace is now one-to-one
across R2-R19.
2026-08-12 11:42:15 +02:00
Michał Pierzchała 52402aec4d refactor(daemon): split touch interaction orchestration into semantic modules (#1748)
* refactor(daemon): split touch interaction orchestration

Closes part of #1691: interaction-touch.ts becomes a router; press, fill,
direct-iOS, shared runtime, Android readiness, and response projection each
own one module. Behavior is unchanged.

* test(daemon): split touch interaction coverage by module

Redistributes all 87 discovered cases across the new module topology and
re-keys the two touch-family fallow baseline entries to the paths that now
hold the same (net one fewer) findings.

* docs: point ADR 0014 at the merged Android readiness regression file

* refactor(daemon): give targeted-touch admission its own module

Keeps interaction-touch-press.ts inside the 300-line budget after the
complexity decomposition: admission (surface/capability/button policy, target
parsing, @ref staleness and mutation admission) answers its own question.

* refactor(daemon): drop the redundant targeted-touch label alias

* test(daemon): split touch suites along the new production seams

Adds the press-admission suite the production module was missing and splits the
four over-budget suites along new production seams (direct-iOS eligibility,
Android ref freshness, touch payload). Every suite installs the full device mock
set: three Android-session cases regressed to TOOL_MISSING on a runner without
adb when the mock set was trimmed per file.
2026-08-11 20:06:59 +02:00
Michał Pierzchała 73b54c15c4 docs: amend ADR 0019 with broader-migration governance (#1738)
Amends ADR 0019 for migration beyond the adoption checkpoint:

- status: recordings command unit recorded as completed; no further
  command unit is authorized by Status — subsequent units are planned,
  budgeted, and authorized through the successor tracking issue
- section 6: explicit none platform-execution mode with invariants; the
  cutover gate applies to platform-executing descriptors only and rejects
  a false none declaration; silent registry-entry mode defaults prohibited
- section 8: move-dominated size accounting, deprecated surfaces die on
  legacy at the next major, two evidence tiers, one parametrized cutover
  gate, capability buckets deleted per unit
- section 9: one bind per handler, side-effect-free facts inspection,
  single defineUse, preferred operations require a recorded measurement
- section 10: cross-cutting facets land with their first consuming unit,
  evidence-gated startup recovery, two-phase gateway shutdown, teardown
  steps ride their owning domain, all-edge-kinds end-state layering rule
  over production daemon modules
2026-08-11 16:24:09 +02:00
Michał Pierzchała f5d9789764 feat: enforce local device claims and reconcile stale owners (#1735)
* feat: enforce local device claims

* fix: address device claim review feedback

* fix: persist canonical daemon claim state directory
2026-08-11 16:18:45 +02:00
Michał Pierzchała 62001cf210 refactor(record): derive session recording from the publication lifecycle (#1719)
* refactor(record): derive session recording from the publication lifecycle

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

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

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

What this buys, beyond one less field:

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 10:58:03 +02:00
Michał Pierzchała 1b2e786128 refactor: move screen recording onto platform runtime (#1724) 2026-08-11 10:24:57 +02:00
Michał Pierzchała c7242f877f refactor: extract durable capture resource lifecycle (#1720) 2026-08-11 10:08:27 +02:00