Commit Graph

17 Commits

Author SHA1 Message Date
Michał Pierzchała d8e03aea9b refactor: migrate screenshot to request-bound runtime (#1878)
Retires the last dispatchCommand edges for screen capture: the generic-route
command, the sparse-snapshot fallback, and the Android snapshot-timeout evidence
capture all admit exact owner facts and bind once (ADR 0019, cutover rule R39).

--overlay-refs becomes part of the declared use, so a target that can capture
pixels but not a tree is refused before anything is written to disk.
2026-08-19 17:33:48 +02:00
Michał Pierzchała 6984a1e095 fix(layering): list the whole zone when R10's type-cycle ceiling is exceeded (#1852)
* fix(layering): list the whole zone when R10's type-cycle ceiling is exceeded

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

Closes #1837

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

Review nit: the overflow is net growth over the ceiling, not a join count
(two joins and one departure print "1"), so the message no longer claims N
members joined.
2026-08-19 11:08:02 +02:00
Michał Pierzchała 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 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 d5f11f6e2f refactor: import package types directly — no internal re-export laundering (#1640)
* refactor: import package types directly instead of re-exporting from internal modules

Post-#1636 review feedback: internal src modules were re-exporting package
types (export type { X } from '@agent-device/...'), giving one declaration
several import paths and hiding its provenance. New rule applied repo-wide:
internal modules import directly from the owning package; only published
entry surfaces (src/sdk/* entries, client-types, finders, metro composition,
remote-config-schema) may re-export.

Eleven internal re-exports removed and ~110 import sites redirected to the
packages, the big two being CommandFlags (core/dispatch chain, 39 sites) and
SessionAction (daemon/types.ts, 19 sites). Two were already dead
(RefFrameEffect via daemon-command-registry, DiffSnapshotCommandResult via
capture/runtime/snapshot). Entry-surface chains now re-export from the
package rather than laundering through a second internal module
(client-types/client-metro MetroBridgeScope).

Side effect: the R9 type cycle shrinks again, 49 -> 47 (daemon-server
19 -> 17); ceilings lowered to match.

* refactor: drop command-schema's CliFlags re-export (#1640 review P2)

The one consumer (cli/parser/args.ts, a multi-line import the sweep's
single-line scan missed) now imports CliFlags from contracts/command;
FlagDefinition/FlagKey stay — they are src-declared types, not package
laundering.
2026-08-06 15:22:51 +02:00
Michał Pierzchała d5f99bab1c refactor: sink backend.ts's cycle-closing types below both zones (#1632) (#1636)
backend.ts imported RepeatedInput up from commands/command-input.ts and
ScreenshotResultData up from utils/screenshot-result.ts — the interface hub
typed in terms of the zones that depend on it, R6's textbook inversion shape.

- RepeatedInput now lives in @agent-device/contracts/interaction;
  command-input.ts re-exports it for its existing importers.
- ScreenshotResultData already had a byte-identical canonical declaration in
  contracts/snapshot-types.ts (exported via contracts/capture); the utils
  copy is now a re-export of it, deleting the duplicate outright.

Measured member-by-member: the R9 type cycle collapses 76 -> 49 files.
backend.ts, runtime-contract.ts, commands/runtime-types.ts, and
commands/runtime-common.ts all leave the component (27 files stranded out at
once); zone ceilings lowered to the measured values (commands 33 -> 14,
platforms 7 -> 2, root 5 -> 3, daemon-server 20 -> 19) and CONTEXT.md's hub
list recomputed (core/dispatch.ts 8, command-catalog.ts 7, resolution.ts 6,
command-descriptor/registry.ts 6). No TYPE_INVERSION_BASELINE additions.
2026-08-06 13:25:07 +02:00
Michał Pierzchała 74efcbbd84 fix(snapshot): one-shot recovered warning for internally armed penalties (#1590)
* fix(snapshot): one-shot recovered warning for internally armed penalties

The deferred-capture suppression assumed the capture that armed the
XCTest-channel penalty already rendered the full 'overly complex or slow
accessibility tree' warning. Internal captures (selector resolution,
settle observation loops, system-modal probes) can arm the penalty
without any user-facing render, leaving the next public snapshot with
only the structured verdict and no CLI warning line.

The runner cannot tell user-facing from internal captures, so the daemon
now holds a per-session one-shot latch (snapshot-quality-latch.ts)
applied at the snapshot/diff response seam: a genuine recovered render
sets it silently, the first public 'deferred' verdict without the latch
re-renders the full warning once and sets it, a healthy public verdict
clears it (the penalty window is over), and an app switch supersedes it.
Internal observation responses (observationOnly) neither consume nor
clear the latch.

Follow-up to PR #1587 review (non-blocking hardening).

* chore(layering): declare the deferred-warning latch owner and ratchet baseline

The R7 session-state gate requires every SessionState field to have a
declared writer owner: recoveredSnapshotWarningLatch is owned solely by
snapshot-quality-latch.ts (matching the field's 'managed only through'
contract), and the R10 pressure baseline grows deliberately to
23 writer-owned fields / 29 owner claims. Also oxfmt-formats the new
latch test.

* fix(snapshot): latch on the captured verdict, not the retained session snapshot

Review P2 on #1590: the latch seam read a diff capture's verdict back from
session.snapshot, but an empty ref-scoped capture deliberately retains the
previous stored snapshot (shouldKeepCurrentSnapshot) — so a deferred
capture could consult a retained healthy verdict, clearing the latch and
omitting the one-shot warning.

The daemon snapshot backend now fills a per-request CapturedSnapshotQuality
slot on every capture, and the seam latches on that just-captured verdict
for both snapshot and diff. New production-path regression: an empty
ref-scoped diff over a retained healthy snapshot with a deferred capture
warns once (verified red against the previous seam).

* test(snapshot): pin app-switch latch supersession through the dispatch seam

Cross-vendor review follow-up: the app-switch transition was pinned only at
the pure-function level; a regression in how the seam keys the latch by the
session's appBundleId would not have been caught. Two-dispatch integration
test: latch held for app A, bundle switched, app B's first deferred verdict
warns once and rekeys the latch.
2026-08-04 19:12:17 +02:00
Michał Pierzchała 761317deb7 refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5) (#1555)
* refactor(replay): move the dependency-free engine leaves into packages/ad-replay

Stage A of the #1478 P5 extraction: vars, plan-digest (+canonical-json,
sole consumer), the target-identity classification core, report-action,
and suggestion-ranking move verbatim; imports updated. The package facade
temporarily re-exports the moved symbols so root consumers keep compiling;
a later stage narrows it to inspectAdReplay/runAdReplay only.

* chore(layering): register packages/ad-replay in the workspace and DAG

* refactor(replay): define the three-operation replay selector port with dual adapters (#1478 P5)

* refactor(daemon): route replay handlers through the selector port (#1478 P5)

* refactor(replay): split target verification into engine policy and daemon authority (#1478 P5)

* refactor(replay): move the .ad step loop behind inspectAdReplay/runAdReplay (#1478 P5)

* refactor(replay): lock the ad-replay façade to its real consumers (#1478 P5)

* test(replay): prove shared-id demotion on both selector-port adapters (#1555 review)

* fix(replay): restore invalid replayBackend rejection on the native path (#1555 review)

* refactor(replay): move shared .ad vocabulary to its owner, packages/ad-script (#1555 review)

* refactor(replay): neutral step/run outcomes and digest/resume behind inspectAdReplay (#1555 review)

P1 "do not smuggle daemon wire failures through a generic": drop the
TResponse generic from AdReplayStepRuntime/runAdReplay. executeStep and
handleActionFailure now return neutral tagged AdReplayStepOutcome/
AdReplayStepFailure values (kind/message/artifactPaths only); runAdReplay
returns a neutral completed/failed AdReplayRunOutcome. The engine never
holds or returns a DaemonResponse. The daemon adapter
(createAdReplayStepRuntime, session-replay-runtime.ts) keeps its real wire
response in a local side-map as it builds each neutral outcome, and
runReplayScriptFile reads it back once runAdReplay reports which step
failed, so the final response is byte-identical to before this split.

P1 "parsing/planning/digest/resume must also occur behind runAdReplay":
relocate computeReplayPlanDigest's call site and the --from/--plan-digest
resume-point math (resolveReplayEntryIndex) behind inspectAdReplay's
manifest as planDigest and a resolveEntryIndex closure. Neither is a new
top-level export -- inspectAdReplay/runAdReplay stay the only two. Timing
is preserved exactly (still called eagerly in prepareReplayPlan, before
prepareReplaySession's coordinator-mutating side effects) since moving
resume validation to run inside runAdReplay itself would let a rejected
--from request mutate coordinator/session state first -- a real ordering
hazard, not just a cosmetic one.

computeReplayPlanDigest/ReplayPlanDigestMetadata/resolveReplayEntryIndex
leave the ad-replay façade; request-router-repair-expired.test.ts and
prepareReplayPlan read the digest/resume result off the manifest instead.

* refactor(replay): relocate classifyTargetBindingMatch and pin the ad-replay façade (#1555 review)

P1 "complete the binding façade instead of documenting deviations":
classifyTargetBindingMatch never had a real consumer reachable through
inspectAdReplay/runAdReplay -- both its callers (the daemon's record-time
self-check in session-target-evidence.ts and its replay-time
classification wrapper in session-replay-target-classification.ts) are
daemon files that imported it directly. It interprets TargetAnnotationV1
evidence semantics shared beyond the engine, so it moves to
packages/ad-script alongside target-annotation-identity.ts (new
target-annotation-classification.ts + its test), and both daemon call
sites now import it from there instead of @agent-device/ad-replay.

One deviation remains and is reported rather than papered over per the
review's own instruction: the four target-verification policy functions
(planPreDispatchTargetVerification, planPostResolutionTargetVerification,
deriveReplayTargetGuardMismatchEvidence,
deriveWaitLandmarkMismatchEvidence) and the ReplaySelectorPort type
family stay exported. Their sole caller,
session-replay-target-verification.ts, interleaves these pure decisions
with daemon-only async work (capture, SessionStore, coordinator/resume
stamping, wire shaping) that must stay outside the engine by design;
moving their call sites to live only behind runAdReplay would require
restructuring that whole orchestration into new fine-grained
AdReplayStepRuntime capabilities, which is out of scope for this pass.
See packages/ad-replay/src/index.ts's header comment for the full
reasoning.

P1 "add the reviewer-required exact exported-symbol gate": adds
readNamedExports (scripts/layering/package-boundaries.ts), a small
parser over a façade's `export { .. } from`, `export type { .. } from`,
and direct-declaration forms, and pins @agent-device/ad-replay's exact
21-symbol export list in package-boundaries.test.ts. Plant-verified: a
stray `export const` addition failed the assertion; removed it and the
gate went green again.

* refactor(replay): drive target verification from the engine step loop (#1555 review)

Moves the verify-then-dispatch decision flow into packages/ad-replay's
step loop so the four target-verification policy functions
(plan{PostResolution,PreDispatch}TargetVerification,
derive{ReplayTargetGuardMismatch,WaitLandmark}MismatchEvidence) become
engine-private and leave the ad-replay façade. The daemon
(session-replay-target-verification.ts) shrinks to the narrow
AdReplayStepRuntime capabilities the engine drives: routing
(beginTargetVerification), capture (captureObservation), classification
(classifyTarget), dispatch (dispatchStep), and wire-building
(buildRecordedUnverifiableFailure, buildTargetBindingFailure,
buildPostDispatchTargetBindingFailure). Wire output and replay-compat
stay byte-identical; the exact-symbol façade gate is updated to the
shrunken export list.

* refactor(daemon): decompose the replay adapter's two over-threshold functions (#1555)

* refactor(replay): fold #1554's keep-session terminal-lifecycle policy into the ad-replay engine

Rebasing p5/extract-ad-replay onto main pulled in #1554's --keep-session
feature, which had grown its own daemon-side terminal-close-suppression
predicate (session-replay-terminal-lifecycle.ts's
resolveSuppressedTerminalCloseIndex/countExecutedReplayActions) independently
of this branch's own engine-side one (step-loop.ts's
isRepairArmedTerminalCloseAction). Both are the same decision family — replay
--keep-session and an active --save-script repair now share ONE structural
resolution (resolveSuppressedTerminalCloseIndex, generalized to "terminal
among EXECUTABLE actions" rather than the old physical-last-index check) and
one suppression check inside runAdReplay, gated on keepSession OR
runtime.isRepairArmed(). AdReplayRunRequest grew a keepSession field; the
neutral 'replayed' count in AdReplayRunOutcome is now computed inline in the
loop instead of the daemon's old actions.length - entryIndex approximation.

requireLiveSessionForKeepSession (the --keep-session live-session
postcondition) stays daemon-side, inlined into session-replay-runtime.ts,
since it inspects SessionStore state the engine never sees. The daemon-only
session-replay-terminal-lifecycle.ts this arrived with is deleted entirely —
its isExecutableReplayAction was a duplicate of the engine's own.

runReplayScriptFile's Maestro-format routing (including the new --keep-session
Maestro rejection) was extracted into routeMaestroReplay to keep the function
under fallow's complexity threshold after re-threading keepSession through it.

Added packages/ad-replay/src/internal/__tests__/step-loop.test.ts covering the
unified suppression decision (both keepSession and repair-armed) directly
against runAdReplay, including the terminal-among-executable-actions case with
a trailing nested replay marker. The daemon-level integration tests (6 tests
in session-replay-terminal-lifecycle.test.ts, exercising the same behavior
through runReplayScriptFile) and the SDK provider-scenario test
(active-session-script-publication.test.ts) needed no changes and pass
unmodified.

* refactor(daemon): decompose session-replay-runtime.ts into three modules (#1555)

Splits the ~1096-line replay runtime into cohesive pieces, keeping
session-replay-runtime.ts as thin orchestration (~240 LOC):

- session-replay-runtime-engine-adapter.ts: the AdReplayStepRuntime
  adapter (createAdReplayStepRuntime, the build*Failure capability
  implementations, and the lastResponse/lastObservation side-map
  mechanics), extracted verbatim.
- session-replay-runtime-plan.ts: extended with the plan-side helpers
  (validateReplayBackendFlag, inspectReplayPlanManifest,
  resolveReplayPlanEntryIndex, prepareReplayPlan, routeMaestroReplay)
  alongside the buildReplayMetadataFlags helper already there —
  buildReplayMetadataFlags is now module-private since its one caller
  moved into the same file. Also introduces ReplayScriptFileParams,
  named here (instead of derived via Parameters<typeof
  runReplayScriptFile>) so routeMaestroReplay can reference the shape
  without importing back from session-replay-runtime.ts.
- session-replay-runtime-session.ts (new): session preparation
  (prepareReplaySession and its coordinator arming/repair-preflight
  helpers), extracted verbatim.

Coordinator ownership is unchanged: createReplayCoordinator is still
constructed only in session-replay-runtime.ts, matching
replay-coordinator-ownership.test.ts's allowlist as-is — every
extracted module receives the already-constructed ReplayCoordinator as
a parameter. Pure move; no behavior change.

* test(replay): cover pre-step artifact ordering and resume-before-mutation (#1555)

Two invariants found during the P5 decomposition pass now have direct
counterfactual-verified coverage:

- packages/ad-replay/src/internal/__tests__/step-loop.test.ts: a
  post-dispatch target-binding mismatch (dispatchWithGuard) must report
  the accumulated PRE-step artifact snapshot it was called with, never
  the artifacts the failed dispatch itself produced. Verified red by
  swapping the buildPostDispatchTargetBindingFailure call to
  outcome.artifactPaths.

- src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts: a
  rejected --from/--plan-digest resume must never reach
  prepareReplaySession's coordinator-mutating writes (the R2 ordering
  invariant) — a pre-armed repair transaction and corrective-resume
  watermark are asserted byte-for-byte unchanged after rejection.
  Verified red by calling prepareReplaySession before honoring the
  plan-validation rejection.

* fix(ad-replay): enforce the exact two-entrypoint facade (#1555 review P1)

packages/ad-replay/src/index.ts now exports exactly two value symbols,
inspectAdReplay and runAdReplay, and zero types — formatReplaySuccessMessage
(presentation) moves beside its one caller in session-replay-runtime.ts, and
every type a root daemon file needs is derived structurally off the two
entrypoints in the one new src/daemon/ad-replay-facade-types.ts module
instead of being named off the façade.

scripts/layering/package-boundaries.ts's readNamedExports is rewritten on
oxc-parser's own static-export table instead of a regex, so it can no longer
silently miss a widening export form: a bare `export *` re-export or an
`export default` now throws (an un-enumerable, and therefore un-pinnable,
export), while `export * as ns` and every other enumerable form is still
counted. The pinned exact-symbol assertion in package-boundaries.test.ts is
narrowed to ['inspectAdReplay', 'runAdReplay'].

* fix(ad-replay): translate wire failures before the engine boundary (#1555 review P1)

AdReplayDispatchOutcome's guard-mismatch/landmark-mismatch variants carried
a generic `details: Record<string, unknown> | undefined` bag straight off
the wire response — a daemon wire projection crossing into the engine even
though the outcome itself was already a neutral type. The daemon adapter
(session-replay-runtime-engine-adapter.ts) now narrows that bag into the
typed AdReplayGuardMismatchEvidence/AdReplayLandmarkMismatchEvidence shapes
(observed identity, expected/observed structural denotation, ancestry
entries, match count) before returning the outcome; the unknown-parsing
readers move there with the wire-reading responsibility they always were.
target-verification.ts's deriveReplayTargetGuardMismatchEvidence/
deriveWaitLandmarkMismatchEvidence now consume only the typed values — no
`unknown`-valued record type remains on any engine-crossing signature.

* fix(ad-replay): move variable semantics/planning behind runAdReplay (#1555 review P1)

The daemon assembled the `${VAR}` scope (buildPreparedReplayScope) and
interpolated actions at two independent call sites: dispatch's own
(invokeReplayAction) and target verification's separate one
(resolveTargetVerificationEntry) — duplicated orchestration the P5 design
assigns to the engine.

runAdReplay's request now carries the raw scope INPUTS (varSources: plain
builtins/file/shell/cli-env data, plus actionLines/actionSourcePaths/
resolvedPath for interpolation-error location) instead of a built scope; the
engine builds the scope and resolves each action exactly once per step,
handing the RESOLVED action to dispatchStep/beginTargetVerification while
every other capability still receives the ORIGINAL recorded action (a
target-binding divergence reports the recorded selector, never an expanded
${VAR}). This is the one resolution site now — session-replay-action-runtime.ts's
invokeReplayAction and session-replay-target-verification.ts's
resolveTargetVerificationEntry no longer hold a scope or call
resolveReplayAction themselves.

Scrub-value collection (collectReplayScrubbableVarValues, for divergence-report
redaction) is kept single-sourced in the engine too: it's computed from the
engine's own live scope and threaded to each build-failure/handleActionFailure
capability as an explicit scrubVars argument, rather than the daemon
recomputing it from a second scope object (which would have gone stale,
since expandedBuiltinNames tracking now only happens engine-side).

The Maestro replay path's own daemon-side vars usage is unrelated (a
different engine) and is out of scope here.

* fix(ad-script): make ${VAR} interpolation a linear scanner

CodeQL flagged the interpolation regex's fallback group as js/polynomial-redos
once vars.ts moved into packages/ (library-input classification): every
${NAME:- prefix of an unclosed input rescanned to end-of-string, quadratic
overall — 1,857 ms measured on 20k repetitions of '${A:-['. Replaced with a
single-pass scanner; failed fallback scans emit their span verbatim and resume
after it (escape-pair alignment is identical from every candidate start inside
the span, so no later candidate can terminate where the failed scan could not).
Equivalence: 200k-trial differential fuzz against the retired regex over the
adversarial alphabet, zero mismatches; both adversarial shapes now resolve in
1-2 ms.

* refactor(ad-replay): typed façade replaces the zero-type rule (#1555 structural-quality review)

Reverses the exact-two-value zero-type export shape #1555's second review
pass established: it forced every root type derivation through one shim
(src/daemon/ad-replay-facade-types.ts) and left four daemon-side twin types
(TargetVerificationEntry, TargetClassificationOutcome,
TargetBindingFailureEvidence, ReplayVerifiedTargetGuard) plus a
toDaemonEvidence copy translator shadowing the engine's own shapes.

packages/ad-replay/src/index.ts now exports inspectAdReplay/runAdReplay
(unchanged, still the only two values) plus the neutral vocabulary their
signatures are built from, by name — following packages/maestro's façade
precedent. The exact-symbol gate in scripts/layering/package-boundaries.test.ts
is widened to pin the full sorted list (values + types).

The four daemon twins are deleted; session-replay-target-verification.ts and
session-replay-runtime-engine-adapter.ts now use the engine's own
AdReplayVerificationEntry/AdReplayTargetClassification/
AdReplayTargetBindingEvidence/AdReplayVerifiedTargetGuard directly.
TargetBindingDivergenceBuilt's array fields are now readonly-compatible, so
toDaemonEvidence's copy is gone — evidence flows through unchanged.

* fix(ad-replay): honor the selector port's own contract in the parse gate

target-verification.ts's planPreDispatchTargetVerification used
resolveRecordedTarget (operation 2, resolve) over an empty node tree purely
to read its parse-invalid reason — a resolve call standing in for a parse
call, even though readSelectorExpression (operation 1, parse) exists to
answer exactly that question and was already unused inside the engine.

Replaced with port.readSelectorExpression('ordinary', [token]). The mapping
is not 'invalid' -> skip: production's 'ordinary'/'wait' grammars only ever
record a boundary once it has already parsed, so a single malformed token
can only come back 'not-applicable' there ('invalid' is unreachable from
this call site on the production adapter). Both non-'expression' outcomes
map to skip, matching the historical behavior (a single parse-invalid reason
covered both cases). platform dropped from the function's params — it was
only ever threaded to the resolve call this replaces.

Added a contract-suite cell pinning the exact (diverging) discriminant each
adapter reports for a selector-shaped-but-malformed bare token, and why the
divergence is harmless for the one real consumer.

* refactor(ad-replay): split step-loop.ts and shrink the daemon adapter (#1555 structural-quality review)

step-loop.ts (810 LOC) splits three ways, following packages/maestro's own
precedent:
- internal/runtime-port-types.ts: the AdReplayStepRuntime boundary
  vocabulary (all the neutral types the engine/daemon exchange).
- internal/verify-dispatch.ts: verifyAndDispatchStep + its dispatchNoGuard/
  dispatchWithGuard helpers.
- internal/step-loop.ts: runAdReplay itself plus the terminal-close/
  executable-action structural logic (isExecutableReplayAction,
  resolveSuppressedTerminalCloseIndex).

packages/ad-replay/src/index.ts's type exports now source from
runtime-port-types.ts. step-loop.test.ts's AdReplayStepRuntime import moves
to the new path (no assertion changes).

src/daemon/handlers/session-replay-runtime-engine-adapter.ts (553 LOC after
item 1's twin removal) shrinks to 294 via two further extractions:
- session-replay-dispatch-narrowing.ts: the wire `details` bag -> typed
  evidence narrowing and dispatch-failure classification.
- session-replay-runtime-step-support.ts: ReplayStepContext (moved here to
  avoid a cycle with the adapter, which re-exports it by name) plus the
  failure-wrapping/diagnostics-support helpers.

Final LOC: adapter 294, dispatch-narrowing 148, step-support 153,
step-loop 225, verify-dispatch 246, runtime-port-types 374.

* test(ad-replay): package-local tests for resume.ts/target-verification.ts + terminal-lifecycle test rename

resume.test.ts covers resolveReplayEntryIndex directly (previously only
exercised transitively through the daemon's session-replay-runtime-plan
tests): no --from/--plan-digest, the paired-flags requirement, in-range
--from, out-of-range rejection, stale-digest rejection, the authorized
empty-tail boundary (actionCount + 1) gated on a matching watermark, and the
unperformed-record-and-heal growth check. Counterfactual run and restored:
widening describeOutOfRangeResumeFrom's bound turns the out-of-range/
empty-tail-without-watermark assertions red (2 failures observed).

target-verification.test.ts covers all four engine policy functions
directly: the two plan* pre-capture gates and the two derive* post-dispatch
evidence builders, including item 2's own new decision surface (a fake
ReplaySelectorPort proving both non-'expression' readSelectorExpression
outcomes map to skip). Counterfactual run and restored: narrowing the check
to the literal `'invalid' -> skip` reading turns the 'not-applicable' case
red (reports recorded-unverifiable instead of skip).

session-replay-terminal-lifecycle.test.ts renamed to
session-replay-runtime-keep-session.test.ts: its production module
(session-replay-terminal-lifecycle.ts) was already deleted by the #1554
fold-in, and its six cases drive the full runReplayScriptFile round trip
against a real SessionStore (including daemon-only postconditions the
engine's step loop never reaches) rather than testing engine policy through
the façade in isolation — the engine's own terminal-close-suppression
decision already has direct, cheaper coverage in step-loop.test.ts. No
assertion changes; both files' header comments cross-reference the split.

* refactor(ad-replay): compute scrub values once per step, one name end to end

collectReplayScrubbableVarValues(scope) was called fresh at 5 separate
return points inside one verifyAndDispatchStep invocation plus once more in
handleActionFailure — always the same result, since nothing between them
mutates scope. step-loop.ts's runAdReplay now computes scrubVars ONCE per
step, right after resolveReplayAction (the one call that can grow the
scope's expanded-builtins set), and threads it as a plain
readonly AdReplayScrubValue[] value; verify-dispatch.ts no longer imports
ReplayVarScope or collectReplayScrubbableVarValues at all.

"One name" end to end: the daemon's TargetBindingDivergenceContext.scrubVars
and withReplayFailureDiagnostics's scrubVars param used a separately-derived
ReturnType<typeof collectReplayScrubbableVarValues> (mutable array) instead
of the engine's own AdReplayScrubValue, requiring a [...scrubVars] copy at
every daemon call site to satisfy the mutable-array type. Both now use
readonly AdReplayScrubValue[]/readonly ReplayVarScrubEntry[] (structurally
identical, already readonly-safe downstream — scrubReplayVarValues and
createReplayDivergenceSanitizer already accepted readonly arrays), so the
four [...scrubVars] copies in session-replay-runtime-engine-adapter.ts are
gone.

* fix(daemon): make lastObservation genuinely per-step, not per-run

createAdReplayStepRuntime's lastObservation closure lives for the whole
replay run (one factory call covers every step), but was never reset
between steps. Every current buildTargetBindingFailure call site happens to
be preceded by this same step's own captureObservation, so the
`lastObservation ?? { reason: 'observation-missing' }` fallback could never
actually fire — but if it ever did (a future call path reaching
buildTargetBindingFailure without capturing first), it would silently
attach the PREVIOUS step's screen instead of reporting the missing-capture
condition the fallback message claims.

armStep runs exactly once per step, before any of that step's other
capabilities (verified against step-loop.ts's runAdReplay loop order) — the
natural per-step boundary. It now clears lastObservation first. No behavior
change on any reachable path today (full daemon + ad-replay suite: 1766/1766
green); an unrelated device-claim-prune contention flake was observed once
and did not reproduce on isolated or full-suite reruns.

* docs(ad-replay): fix decayed review-changelog comments naming defunct symbols

Four comments named symbols/paths that no longer exist, left behind by
earlier review passes describing PR history rather than the current
constraint:
- session-replay-runtime-step-support.ts / session-replay-runtime.ts (2
  sites): referenced a function called executeStep, which was never
  reintroduced under that name after the P5 split — the actual mechanism is
  the runtime's dispatch/build-failure capabilities recording into the
  lastResponse side-map.
- session-replay-runtime.ts: referenced an engine collectArtifactPaths
  capability that does not exist — artifactPaths is a daemon-side Set the
  adapter mutates via collectReplayActionArtifactPaths.
- packages/ad-replay/src/internal/selector-port.ts: pointed at
  ./testing/in-memory-selector-port.ts, the in-memory adapter's pre-stage-D
  location — it has lived at
  src/__tests__/test-utils/in-memory-replay-selector-port.ts since.
- session-replay-repair-hint.ts / session-replay-runtime-step-support.ts (2
  sites): named target-identity.ts, which does not exist (the real file is
  target-identity-node.ts); the second site additionally mislabeled
  classifyReplayTarget as engine-side when it is daemon-side
  (session-replay-target-classification.ts).

Comment-only; no behavior change.

* refactor(ad-script): move declaredScriptPlatform to its natural shared owner

packages/ad-replay/src/internal/inspect.ts's declaredScriptPlatform and
src/daemon/replay-device-selection.ts's readScriptReplaySelection each kept
their own copy of the same "platform declared before the first open" scan
over runtime/open actions — .ad script semantics, not engine or daemon
policy, needed independently by ad-replay's plan-digest precedence and the
daemon's device-selection platform resolution.

Verified this was a genuine duplicate (not the single-sourced state I
initially reported): readScriptReplaySelection's platform-tracking loop
computes the identical result via a differently-shaped traversal fused with
its own app-target scan.

resolveDeclaredScriptPlatform now lives in packages/ad-script (its natural
owner: the one package both ad-replay and the daemon already depend on,
avoiding the R11 issue that justified the original duplication). The
daemon's app-target scan stays its own separate pass; fusing it back into
the shared function would smuggle a daemon-only concern into ad-script for
no measurable cost (the actions array is small, and the shared function
already stops at the same point the app-target scan needs to look).

* docs(ad-replay): fix package.json description to match the current façade

Described "target-identity, variable substitution, plan-digest, and report
primitives" — the wide pre-#1555-review façade shape. Vars/identity/report
vocabulary moved to ad-script/daemon across the P5 and #1555 review passes;
the package now exports exactly inspectAdReplay/runAdReplay plus the
neutral AdReplayStepRuntime vocabulary. Description updated to match.

* refactor(daemon): fold the step-support fragment back into the engine adapter

A simplicity audit judged session-replay-runtime-step-support.ts a
size-target fragment, not a concern boundary: four unrelated concerns,
one consumer, and a header comment admitting it existed to satisfy the
<300 LOC metric. Folded back; the previously-exported helpers are
module-private again; the adapter's honest size is renegotiated from the
plan metric (dispatch-narrowing stays extracted — it has one nameable
job).
2026-08-03 17:25:13 +02:00
Michał Pierzchała 67f3d09d95 refactor(daemon): session script publication behind one capability (#1478 P4a) (#1532)
* refactor(daemon): add the tagged script-publication aggregate

First step of P4a. Nine co-resident optional SessionState fields encode two
lifecycles plus a shared output target, with nothing in the shape saying the
lifecycles are disjoint — so readers re-derived that from field combinations
and writers had to remember which siblings to clear.

The aggregate makes both invariants structural: a session publishes nothing,
authors ordinarily, or is under repair; and force lives inside the target, so
retargeting replaces the authorization along with the path.

Three corrections after an adversarial review of the first draft:

- The target is a default|explicit union, not a mandatory path. A bare
  'open --save-script' arms with no path and lets the writer resolve a
  daemon-owned destination at write time, and force can be granted before any
  path exists. Eagerly materializing a default path would have silently changed
  retarget semantics, because today's check requires a previously persisted
  path — so 'open --save-script --force' then 'close --save-script=out.ad' is
  not currently a retarget and the grant survives. That behavior is preserved
  here and flagged in the docblock as a probable #1258 gap; tightening it is a
  product change and belongs in its own commit.

- The repair status relation is not linear. A failed commit followed by
  'replay --from' demotes complete back to armed, so demoteRepairToArmed exists
  and deliberately RETAINS the close receipt: the platform close already
  succeeded for that operation identity, and dropping it would re-dispatch a
  close on retry — which is also how a migrator ends up reaching for the
  caller-computed platformCloseSucceeded boolean the brief forbids.

- The receipt doc no longer claims it is set only at close-succeeded and later,
  since the demotion path makes {armed, receipt set} reachable.

Still to come in this PR: both projections, and the writer migration. Note the
brief's seven-file writer inventory omits session-open.ts, which holds the only
two writers of the authoring armed/aborted states.

Refs #1478

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

* refactor(daemon): migrate script publication onto the tagged aggregate (#1478 P4a)

The eight co-resident SessionState fields (scriptRecordingState, saveScriptPath,
saveScriptForce, saveScriptBoundary, saveScriptComplete, saveScriptCommitted,
repairPlatformCloseReceipt, repairSourcePath) are gone; SessionState.scriptPublication
holds the aggregate, and every writer migrated in this commit — no shadow state.

Two daemon-private projections own the writes, enforced by the R7 ownership gate:

- session-replay-transaction.ts (ReplaySessionTransaction): repair arm/demote/
  complete/abort, close receipts, and the uncommitted/boundary/sourcePath reads
  that idle-reap, tombstones, divergence-hold, and the recorder's exclusion key off.
- session-script-publication-capability.ts (SessionScriptPublication): authoring
  arm on open, the recorded --save-script flag ingress, active publication, the
  published transitions, and the effective per-target force decision (#1258).
  The writer keeps the commit transition so idempotence stays colocated with the
  atomic publish.

Failure/retry transitions pinned as the brief requires: platform-close failure
leaves state unchanged (no receipt, retry re-dispatches); publication failure
retains target+force+receipt (same-identity retry skips close dispatch); committed
and aborted are explicit terminal states that drop the receipt.

Design decisions resolved:

- Force retention across a default->explicit retarget is preserved as-is and
  still flagged in resolveScriptTarget's docblock as a probable #1258 gap;
  tightening it stays a separate product change.
- The never-armed 'close --save-script' whole-log publication folds into the
  authoring lifecycle (armed at the recorded close, published in the same
  request) instead of a fourth variant: every close path that reaches the write
  deletes the session, so the transient armed state cannot leak into
  'session save-script' eligibility, whose not-armed-before-this-journey
  rejection is untouched.

One real bug caught by the migrated tests and fixed in resolveScriptTarget: a
bare (pathless) re-arm collapsed an already-materialized explicit target back to
the daemon default, wiping the healed-sibling path on every per-step repair
re-arm and defeating the persisted-force preflight bypass. A bare re-arm now
keeps the previous target and only adds a live force grant.

R7 rows consolidated to one scriptPublication entry (three owners) and the
recordSession row narrowed; the R10 baseline drops to 22 writer-owned fields /
28 owner claims so the consolidation cannot regrow.

Gates: typecheck, lint, format, layering clean; 624 files / 5220 tests pass
(two known contention-flake timeouts reproduce only under full-suite load and
pass in isolation).

Refs #1478

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(daemon): satisfy the Fallow gate by extracting decisions, not suppressing

- scriptPublicationTarget is module-private; both public target reads
  (scriptTargetPath/scriptTargetForce) go through it and nothing else did.
- validatePublicationEligibility splits into a pure ineligibility classifier
  and an error table, so the four rejections read as one decision each.
- prepareSaveScriptSession hands its two arm-time rejections (authoring
  re-arm, EEXIST preflight) to rejectSaveScriptArming and keeps only the
  demote-and-arm flow.
- The repair-record-exclusion provider scenario extracts its three phases
  (arm-and-hold, exclusion contrast, healed-script contract) into named
  helpers; the test body is the journey again.

Refs #1478

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 11:10:09 +02:00
Michał Pierzchała cbe1a57094 refactor(replay-test): extract packages/replay-test (#1478 P3b) (#1525)
* refactor(replay-test): source the manifest device vocabulary from the kernel

`session-test-types.ts` reached `ReplayScriptMetadata['platform']` and
`['target']` through `replay/script.ts` — the native `.ad` engine. A
format-neutral scheduler must not name an engine module, and P5 relocates that
engine into `packages/ad-replay` regardless, so the import had to go before the
scheduler can move.

Both members already resolve to neutral kernel types
(`Exclude<PlatformSelector, 'web'>` and `DeviceTarget` from
`@agent-device/kernel/device`), so this re-sources them directly and the
manifest shape is unchanged. Only the import direction differs.

First increment of P3b; the scheduler still has request-global, engine and
daemon imports to port before the physical move.

Refs #1478

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

* refactor(replay-test): inject the progress sink instead of reading a request global

The scheduler called `emitRequestProgress` in eight places, which reads a sink
out of a request-global `AsyncLocalStorage`. That is ambient authority a
format-neutral scheduler cannot hold once it lives in `packages/replay-test`,
and #1505 recorded it as a shrink-only R10 entry.

The host now injects the capability through the existing
`ReplayTestRuntimeDependencies` seam established in P3a, so no new seam is
invented. `session-replay.ts` supplies `emitProgress: emitRequestProgress`;
`src/request/progress.ts` keeps the sink and its AsyncLocalStorage binding for
every other caller.

The port is deliberately narrower than `RequestProgressSink`: it accepts only
`ReplayTestSuiteProgressEvent | ReplayTestProgressEvent`, so the scheduler is
not handed the ability to emit `CommandProgressEvent`.

Authority narrows again one hop down: `runReplayTestAttempt` spread the whole
dependency bag but uses three of its members and never publishes progress, so
it now takes `Pick<..., 'runReplay' | 'cleanupSession' | 'finalizeAttempt'>`.
That is why no runtime test fixture needed changing — the attempt runtime never
gained the capability in the first place.

Also drops the last two `replay/script.ts` type references from
`session-test-runtime.ts`, so the engine import is gone from that file too.

Reporter contract preserved: `session-test-reporter-values.test.ts` and
`session-test-reporter-values-maestro.test.ts` both pass unmodified (27 tests
green across the five scheduler suites). Typecheck clean.

Remaining scheduler boundary for P3b: `request/cancel.ts`, `replay/format.ts`,
`replay/script.ts` in discovery, `session-store.ts`, `daemon/types.ts`,
`replay-source-discovery.ts`, `core/dispatch*`, `utils/diagnostics.ts`.

Refs #1478

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

* refactor(replay-test): ask the host whether the suite is canceled

The scheduler called `isRequestCanceled(requestId)` in five places. That both
reaches a request-global registry and forces the scheduler to name a daemon
request id as the cancellation key — neither survives the move into
`packages/replay-test`.

The host now binds the predicate to its own request and passes
`isCanceled: () => boolean`. The scheduler asks a question it is entitled to
ask and learns nothing about how cancellation is tracked. `shouldStopReplayTestExecution`
takes the capability rather than a request id, so no scheduler function threads
a daemon identifier for this purpose any more.

`session-test-attempt.ts` and `session-test.ts` no longer import
`request/cancel.ts` at all. It remains in `session-test-runtime.ts`, which does
something different — `registerRequestAbort`, `markRequestCanceled` and the
parent-abort relay are cancellation *binding*, which the brief assigns to the
daemon adapter, so that split is its own step.

Behavior preserved: both pinned reporter characterizations pass unmodified,
32/33 across the five scheduler suites. The one failure is the pre-existing
P2/#1506 discovery-ordering regression, unrelated and untouched here.

Refs #1478

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

* refactor(replay-test): drop the dead request-tracking call from attempt ids

`buildReplayTestAttemptRequestId` wrapped its template in
`resolveRequestTrackingId`, pulling `request/cancel.ts` into the scheduler.

That wrapper substitutes a generated id only when its first argument is an
empty string. The template here always contains `:test:`, so it is never empty
and the wrapper always returned it unchanged — the call is unreachable in this
path. Probed all three shapes (explicit request id, suite-id fallback with a
shard, and degenerate empty inputs); every one returns the template verbatim.

Removing it takes `request/cancel.ts` out of discovery without altering a
single produced id. The scheduler mints attempt identity itself, which is what
the brief asks for.

Evidence the ids are byte-identical: the pinned reporter characterizations
assert exact session strings such as
`default:test:suite-reporter:1-02-retry:attempt-1` and pass unmodified —
30 tests green across the reporter, suite and discovery suites.

Refs #1478

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

* refactor(replay-test): move cancellation binding and diagnostics to the host

`session-test-runtime.ts` held the last two request-globals in the scheduler:
`request/cancel.ts` (registerRequestAbort, markRequestCanceled,
clearRequestCanceled, plus the parent-abort relay) and `utils/diagnostics.ts`.

These are different in kind from the earlier ports. The brief gives the daemon
adapter the job of mapping an attempt id to daemon request identifiers and
*binding cancellation*, while timeout policy stays scheduler-owned. So the
scheduler now receives a per-attempt capability with exactly two verbs —
`cancel()` on timeout and `release()` when the attempt settles — and every
registry interaction, including `relayReplayTestAbortFromParent`, moved to
`session-replay.ts` next to the rest of the adapter.

Diagnostics became a narrow publish capability for the same reason:
`emitDiagnostic` reads a request-global scope. The level vocabulary is spelled
out at the seam rather than imported, so nothing engine- or daemon-shaped
crosses it.

The runtime fixtures drive the real exported host binding rather than a stub.
They assert cancellation through `isRequestCanceled`, and a stubbed binding
would have kept those assertions passing while proving nothing.

24 tests green across the runtime, suite and both reporter characterizations,
which pass unmodified. Typecheck, lint and oxfmt clean.

Refs #1478

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

* refactor(replay-test): split discovery into host inspection and scheduler policy

discoverReplayTestEntries expanded paths, read every file, and called both
engines — readReplayScriptMetadata for .ad, inspectMaestroFlow for Maestro —
plus resolveReplayFormat to choose between them. Four imports a format-neutral
scheduler cannot hold.

Inspection is now the host's discoverSources capability. What stays in the
scheduler is the genuinely neutral half: which sources a --platform filter
runs, which it skips and with what message, and the empty-suite error.

The manifest carries exactly the four fields the scheduler consumes (platform,
target, retries, timeoutMs) plus the reporter's title, per the brief's
instruction not to add more without a demonstrated call site.

The platform tag is what removes the last format leak. The filter used to ask
resolveReplayFormat(...) === 'maestro' to decide whether a missing platform was
disqualifying. It now reads a tag: caller-bound means the invocation supplies
the platform, unspecified means the source declared none. Maestro is what
caller-bound looks like from the scheduler's side, and the format cannot be
recovered from it.

Discovery tests drive the real inspection capability, writing actual .ad and
Maestro sources — a stubbed host half would have kept them green while proving
nothing about the composition they exist to pin.

35 tests green across discovery, suite, runtime and both reporter
characterizations, which pass unmodified. The Maestro one is the direct check
that titles still flow, since they now arrive via the manifest.

Refs #1478

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

* refactor(replay-test): build attempt ids from named segments; trim comments

Review feedback on the attempt-id builder: the comment explained a deletion
that git already records, and it sat above an opaque template literal.

The id is now a segment list joined on ':', so its shape is readable without
prose. Output is byte-identical — the reporter characterizations assert exact
session and attempt strings and pass unmodified.

Applied the same standard to four other docblocks in this PR that narrated
what the code used to do rather than what it does. The durable 'why' stays:
which side of the seam owns what, and why the vocabulary is neutral. The
migration history goes, since git carries it and these docblocks will outlive
the migration.

Refs #1478

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

* refactor(replay-test): move shard device binding to the host

buildReplayTestShardPlan called listDeviceInventory to discover what to shard
across, and buildReplayTestShardFlags constructed daemon CommandFlags for the
nested request. Inventory enumeration, allowlists, simulator set paths,
explicit --device selectors and the too-few-devices error are host concerns;
what is scheduler-owned is deciding how many shards exist and which entries
each one runs.

The scheduler now receives resolved shard targets through a capability. The
target is neutral: id and name for session labels and progress metadata, plus
platform and target, which are already kernel vocabulary. DeviceInfo no longer
crosses into scheduling.

One behavior note: an explicit --device selector could in principle name a web
target, which is not a shardable device. That is now rejected with INVALID_ARGS
rather than widening the neutral platform vocabulary to carry something the
scheduler can never run. Implicit selection already filtered to mobile.

919 of 920 handler tests pass. The one failure, session-test-runner.test.ts
'binds each replay script to its declared platform metadata', fails identically
on clean origin/main in this container and is unrelated: directory discovery
walks with opendirSync/readSync and directory results are deduped but not
sorted, while glob results are sorted, so suite order is filesystem-dependent.

Refs #1478

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

* refactor(replay-test): extract packages/replay-test behind a façade

Completes the P3b extraction. The scheduler, attempt runtime, discovery
policy, sharding distribution, artifacts and neutral types now live in
packages/replay-test/src/internal/, with one package-root export.

The façade takes a neutral ReplayTestSuiteRequest and returns a tagged
ReplayTestSuiteOutcome. DaemonRequest, DaemonResponse and CommandFlags no
longer reach the scheduler; the adapter translates flags and meta in, and the
outcome back to a daemon response. Eight flags were read by the scheduler and
each became a field it owns.

Host work moved to daemon adapters: source inspection (both engines and format
routing), shard device binding and shard-flag parsing, and artifacts-dir home
expansion, which is why the package can resolve paths without SessionStore.

The one remaining shared concern was the timing trace: the host writes video
lifecycle events into the same trace the scheduler owns. Rather than export a
writer from the façade, each attempt hands the host an appendTimingEvent
closure, so the trace format stays private and the authority is scoped to that
attempt.

Tests mirror the topology. Discovery tests split along the seam they now
cross: ordering, traversal and routing are pinned host-side against real files,
filtering policy is pinned in the package against fake sources. The runtime
tests assert the scheduler's cancellation obligation (cancel once on timeout,
always release) against a recording binding, and a new daemon test pins the
adapter's half — registry entries, the parent-abort relay, and detach on
release — so that coverage moved rather than disappeared.

R10 retargeted to packages/replay-test/src/ and the zone ranked alongside
maestro. R11 confirms zero root-src imports from the package.

914 of 915 handler and package tests pass. The one failure,
session-test-runner 'binds each replay script to its declared platform
metadata', fails identically on clean main here: directory discovery walks with
opendirSync and dedupes without sorting, while globs sort, so suite order is
filesystem-dependent.

Refs #1478

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

* refactor(daemon): simplify replay-test request translation

Fallow flagged toReplayTestSuiteRequest at 14 cyclomatic in 18 lines. The
branches were self-inflicted: every req.flags?.x is one, and each optional
field was written as a conditional spread to avoid setting an undefined key.

exactOptionalPropertyTypes is not enabled, so assigning undefined to an
optional field is equivalent and the spreads bought nothing. Destructuring
flags once and extracting two flag readers removes most of the rest.

One correctness note on the simplification itself: the first version used
`artifactsDir && expandHome(...)`, which returns '' for an empty-string flag
where the previous code called expandHome(''). Replaced with an explicit
undefined check so the empty-string path is unchanged.

Refs #1478

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

* test(live): share the replay test-suite harness across iOS and Android

Both live journeys invoked the public test command and then re-derived the
same value-contract assertions by hand — suite totals, per-script status,
replay counts, non-empty JUnit. Those are claims about the published suite
result and are identical on every platform, and they had already drifted: iOS
iterated with readReplayCommands inline, Android cast data.tests at the call
site.

The shared helper owns exactly that boundary. It takes the caller's runStep
rather than binding a context type, so it is not a platform-configured runner
and cannot template a platform's journey.

Everything a platform genuinely differs on stays with the caller: which
scripts run, the retry policy (iOS 2, Android none — itself a claim worth
keeping), which commands each script exercises, and the behavioral evidence.
Both callers keep every verify* call they had.

67 lines removed, 15 added.

Residual risk: this container has no iOS or Android devices, so the live suites
could not be executed here. Typecheck and lint pass; the harness needs a run on
real targets before the claim that behavior is unchanged is evidence rather
than inference.

Refs #1478

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

* test: pin directory enumeration in the platform-binding suite test

The test wrote two scripts into a temp directory and assumed discovery would
return them in creation order. Directory expansion deliberately preserves
filesystem order to match Maestro — only glob expansion sorts, and 'preserves
Maestro directory filesystem order' pins that with a mocked opendirSync. So the
ordering contract is correct; this test's assumption about enumeration was not.

It passes on CI, where small directories usually enumerate in creation order,
and fails on filesystems that do not — identically on clean main, where the
platform-to-script binding appears reversed.

Pinning enumeration the way the discovery tests already do keeps the subject
intact (each script binds to ITS declared platform, and session numbering
follows discovery order) without depending on the host filesystem. The fs
import became a default import because vi.spyOn cannot redefine an ESM
namespace export.

915 of 915 handler and package tests now pass here.

Refs #1478

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

* test: scope the enumeration spy and restore it in a finally

The spy I added restored only on the happy path and asserted on its argument
inside the mock implementation. Either would misfire for anything else sharing
the worker: an assertion thrown from inside fs, or a leaked global opendirSync,
surfaces as a worker crash with no failed test rather than a readable failure.

It now delegates to the real implementation for any directory but this suite's
own, and restores in a finally.

Refs #1478

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

* fix(replay-test): put package tests where they are actually run

Review found the moved package tests were neither executed nor typechecked.
They sat under packages/replay-test/test/, but vitest's unit-core lane includes
packages/*/src/**/*.test.ts, and neither the root nor the package tsconfig
covers a top-level test directory. A plain unit-core run discovered zero files
under the package.

That is why they looked green: my earlier runs passed those paths explicitly on
the command line, which masked that the default run skipped them. The count is
the proof — 550 files/4741 tests before, 553/4753 now, and the delta is exactly
the three files and twelve tests that were being skipped.

The runtime test also imported runReplayTestAttempt from the package specifier,
which the facade does not export. It would have failed the moment it was
discovered. It now imports internally, like the rest of the internal tests.

Also removed replayTestAttemptFailure from the facade: zero consumers outside
the package, so exporting it widened the boundary for nothing. P3 asks for a
one-function facade.

553 test files and 4753 tests pass; lint and the layering guard are clean.

Refs #1478

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

* style: format the facade after removing the unused export

A scripted edit removed the export line but left a stray blank line; oxfmt was
not re-run on that file afterward, so Lint & Format caught what pnpm lint
alone does not.

Refs #1478

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

* fix(replay-test): typecheck the package and fix a type-only import

Review found the moved package tests were transpiled by vitest but never
typechecked: the root typecheck script builds six packages via tsc -b and
packages/replay-test was not among them, so its tsconfig was never used.

That hid a real TS2459. session-test-runtime.test.ts imported
ReplayTestAttemptOutcome from ../session-test-runtime.ts, which imports that
type but does not re-export it. It now imports from ../session-test-types.ts,
where the type is defined.

Adding the package to the tsc -b list closes the gap. Verified empirically
rather than assumed: planting a string-to-number error in a package test makes
typecheck fail, and removing it makes it pass. This is the second finding of
the same shape on this PR — first the tests were not discovered by vitest, now
they were not covered by typecheck — so the gate was confirmed to reach the
files rather than trusted to.

12 package tests pass, lint, format and the layering guard are clean, and
typecheck is clean with the package included.

Refs #1478

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 20:16:18 +02:00
Michał Pierzchała a3ab69a110 refactor(replay-test): neutralize the values crossing the scheduler seam (#1478 P3, part 1) (#1509)
* refactor(replay-test): neutralize the values crossing the scheduler seam

#1478 P3, part 1 of 2. Prepares the replay-test extraction by removing every
non-neutral value that crosses the scheduler seam, in place under `src/`, so the
physical move to `packages/replay-test` is a file move rather than a redesign.

`DaemonResponse` no longer crosses the seam. `session-test-types.ts` typed
`runReplay`/`finalizeAttempt` as returning a daemon response and the scheduler read
`.error.code`, `.error.details`, and `.data.replayed/.healed/.warnings/
.snapshotDiagnostics` off it throughout. That is invisible to R10 today only
because `checkDaemonTypesImporters` skips `src/daemon/`; once the files live in a
package they become external `daemon/types.ts` importers, which the ratchet only
lets shrink. Attempts now resolve as tagged `ReplayTestAttemptOutcome` values
carrying exactly what the scheduler consumes, including an `infrastructure` tag —
classifying an environmental failure needs platform boot-diagnostic vocabulary the
scheduler must not import, so the host decides and the scheduler reads the verdict.
`session-test-outcome.ts` is the one place a daemon response becomes an outcome.

Step events get a narrow per-attempt port. They were emitted from
`session-replay-runtime.ts` and `session-replay-maestro-observer.ts`, both reading
a request-global `AsyncLocalStorage` seeded per attempt. The scheduler now hands
each attempt an `onStep` sink, threaded the way `tracePath` already is; both
engines call it and `withReplayTestActionProgress`/`readReplayTestActionProgress`
are gone. A direct `replay` simply has no sink.

ADR 0012 divergence becomes a neutral leaf. `src/replay/divergence.ts` depended
only on kernel contracts and redaction, yet Maestro constructs divergences too and
CLI/MCP both render them, so P5 could not have moved it into `packages/ad-replay`.
It is now `@agent-device/contracts/divergence`; the renderer's output text is
unchanged.

The progress wire vocabulary moves to `@agent-device/contracts/progress`. It is
serialized by `request-progress-protocol.ts` and reconstructed by the CLI reporter
path, so it belongs below both; `src/request/progress.ts` keeps only the sink and
its AsyncLocalStorage binding.

Together these clear all four of replay-test's recorded R10 migration imports, so
the rule now enforces unconditionally for that module.

Behavior is unchanged. The shipped reporter contract — export spellings,
object/factory loading, hook names, timing/order, value fields, the synchronous
live-hook rule, awaited suite completion, error handling, exit codes — is
untouched, and `session-test-reporter-values.test.ts` passes unmodified. The
`--shard-all` `total`/`runnable` asymmetry is preserved as characterized.

Refs #1478

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

* test(replay-test): pin the Maestro reporter step path against the onStep port

Review finding on #1509: the native `.ad` reporter ratchet exercises only one of
the two `onStep` forwarding chains, so deleting a link in the Maestro chain would
silently stop `onTestStep` for every `test --maestro` run while every existing
reporter test stayed green. Same defect class as the dropped diagnosticId/logPath
(#1501) and the dropped reporter `hint` (#1505).

Maestro is one of P3's two required real adapters and its chain shares no links
with the native one below `runReplayScriptFile`:

  scheduler sink -> runReplayScriptFile -> runTypedMaestroReplayFile
                 -> createMaestroReplayObserver({ onStep }) -> actionStarted -> onStep

Adds a Maestro scenario driving `test --maestro` through the real session handler
and the real reporter registry. It asserts the step payload the engine produces
(`stepIndex`/`stepTotal`/`stepCommand`/`stepValue`, including that a value-less
command stays value-less) together with the attempt/session identity the scheduler
supplies, since that half of the event came from request-global AsyncLocalStorage
before P3. A second case drives a retry so step events must carry attempt-1's
session and then attempt-2's. The flow `name` also pins the reporter `title`, a
value only the Maestro path can produce.

New file rather than an addition to session-test-reporter-values.test.ts: that file
is the pinned characterization and must keep passing unmodified, and Maestro needs
its own vi.mock of core/dispatch for device resolution.

Counterfactual run, both links, each restored after:
  - dropping `onStep` from createMaestroReplayObserver in
    session-replay-maestro-runtime.ts
  - dropping the emitMaestroStep call from actionStarted in
    session-replay-maestro-observer.ts
Each dropped both onTestStep events ("expected [ 'onSuiteStart', 'onTestStart',
…(2) ] to deeply equal [ 'onSuiteStart', 'onTestStart', …(4) ]") and failed both
new cases, while session-test-reporter-values.test.ts passed all 4 — exactly the
hole the reviewer identified.

Test-only; no production change. Bundle output is byte-identical to b339c640f.

Refs #1478

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 08:48:20 +02:00
Michał Pierzchała 1fc9169188 refactor(daemon): consolidate session-script test factories and drop saveScriptDefaultedHealedPath (#1508)
Preparatory slice for P4a (#1478). No aggregate, no transaction type, no
publication-writer migration — those land separately.

Two things:

1. Name the session-script session states in the shared test factories
   (`makeAuthoringSession`, `makeRepairArmedSession`,
   `makeRepairCompleteSession`) and route 40 inline session literals across
   11 test files through them. The `saveScript*` fields are not independent
   — recording without a boundary is ordinary authoring, a boundary without
   `saveScriptComplete` is an ARMED-but-uncommittable repair, and only the
   COMPLETE combination publishes — so re-deriving the combination per test
   buried the distinction each test was actually about. Pure refactor: the
   factories write today's fields and no assertion was weakened.

2. Delete `saveScriptDefaultedHealedPath`. It had zero production readers:
   the writer's refuse-on-exist guard has been uniform since #1235, so the
   flag was written in three places and never consulted. Removing it takes
   its R7 owner row with it and lowers the R10 baseline from 30/42 to 29/40
   (the ratchet fails on a drop too, so this cannot be deferred).


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 07:24:22 +02:00
Michał Pierzchała 0e51007b04 refactor: isolate maestro engine package (#1506)
* refactor: isolate maestro engine package

* perf: deepen maestro facade boundaries
2026-07-30 20:58:20 +02:00
Michał Pierzchała 352428d37a test: characterize replay-test reporter contract and extend R10 (#1505)
* test: characterize replay-test reporter contract and extend R10

P3 of #1478 moves the replay-test scheduler into `packages/replay-test` and
makes attempt identity scheduler-owned. Before production code moves, pin what
a shipped custom reporter actually observes today, and close the import
boundary the extraction has to end up satisfying.

Reporter values (all pinned as shipped, none proposed):
- the `RequestProgressEvent` -> reporter-value projection field by field,
  including key presence for absent optionals and the dropped `command` events;
- `session` provenance across the seam: the start value is always `attempt-1`
  (it is built before any attempt runs), step values track the running attempt
  through the per-attempt AsyncLocalStorage context, and result values carry the
  attempt that produced them, so a retried case reports three different
  sessions to one reporter;
- the shard-scoped session prefix and device identity a sharded run reports;
- module export spelling precedence, the six optional hook names, the live-hook
  vs final-hook error asymmetry, and exit-code recommendation semantics.

Late-timeout finalization/cleanup:
- finalization always runs before cleanup, and the timing trace records that
  order (`finalize_start/stop` then `cleanup_start/stop`);
- a replay settling inside the 2s grace window cleans up once and is not marked
  `timeout_cleanup_pending`, and the raced TIMEOUT response still wins;
- a replay that misses the window defers its second cleanup until the abandoned
  replay settles, and that late cleanup's failure is swallowed.

R10 now rejects replay-test imports from `src/request/**` and engine internals
(`src/replay/`, `src/compat/`, `src/maestro/`, `src/ad-replay/`), with the four
imports that exist today recorded as shrink-only migration entries so the rule
enforces immediately and the extraction must delete them.

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

* test(daemon): assert reporter hint on the real onTestResult value

The failing-suite characterization supplied `hint` as input but never
asserted it on the hook value, so dropping `hint: error.hint` from the
scheduler path left all reporter tests green. Assert it on the real
reporter value so the ratchet catches a shipped field going missing.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 19:05:39 +02:00
Michał Pierzchała 0ee2a86129 refactor: extract contracts workspace package (#1499)
* refactor: extract contracts workspace package

* fix: preserve screenshot diff result contract

* test: stabilize Android keyboard smoke
2026-07-30 17:07:46 +02:00
Michał Pierzchała 2316fd32c5 test: pin daemon modularity migration contracts (#1487)
* test: pin daemon modularity migration contracts

* refactor: tighten daemon modularity ratchets

* test: pin reporter live hook isolation
2026-07-29 18:05:00 +02:00