feat(replay): ADR 0012 migration steps 5+6 — resume + --update retirement#1211
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
|
CI fix pushed as 40c8b2e (heads-up for the in-flight review): the Integration Tests failure was the architecture-progress gate rejecting the new |
thymikee
left a comment
There was a problem hiding this comment.
Adversarial review — ADR-0012 steps 5+6 (resume + --update retirement)
Reviewed in an isolated detached worktree at 2fc999c37. Verified against docs/adr/0012-interactive-replay.md decisions 4 and 1. Live-verified on iPhone 17 Pro sim (C25DBB5B), com.callstack.agentdevicelab. All gates below reproduced locally (check:tooling, check:fallow, full test:unit — 3732/3732 green).
1. BLOCKING — CI is red: new public flags unclassified by the integration-progress gate
gh pr checks 1211 → Integration Tests fails at the "Check Provider-backed integration architecture progress" step:
provider-backed integration progress check failed: unclassified public CLI flags: replayFrom, replayPlanDigest
scripts/integration-progress-model.ts requires every public CLI flag to be bucketed (see the Public CLI flag coverage outside provider-backed integration table). --from/--plan-digest (replayFrom/replayPlanDigest in cli-flags.ts) were added but never added to that classification table — they'd naturally sit in the same "parser/client-only command flags" bucket as replayMaestro/replayExportFormat. This needs a fix before merge; it's not flaky, reproduced identically against the PR head commit.
2. MEDIUM — planDigest deliberately excludes ${VAR} substitution values; verify this is the intended ADR reading, and document it
src/replay/plan-digest.ts computes the digest over the unexpanded ${VAR} text — variable substitution happens later, per-action, at invocation (resolveReplayAction in session-replay-action-runtime.ts). This is deliberate and tested (plan-digest.test.ts: "computeReplayPlanDigest never changes based on unsubstituted ${VAR} text").
Live-reproduced: identical digest regardless of -e value —
replay var-digest-demo.ad -e LABEL_VAR=alpha → digest 53ba9b28...5ba5f
replay var-digest-demo.ad -e LABEL_VAR=zzz_completely_different_beta → digest 53ba9b28...5ba5f (same)
The ADR's own resume paragraph says "edits, include changes, or environment-driven expansion cannot silently retarget ordinal N" — which reads (at least on one plausible interpretation) as requiring digest sensitivity to variable-driven behavior, not just to the presence of a ${VAR} placeholder. The counter-argument for the current design is also in the ADR text: "The only variables available after a resume are explicit script/header, CLI, and shell inputs" — implying CLI (-e) inputs are legitimately expected to vary across a resume, similar to how the ADR puts app-state repair on the caller. Both readings are defensible from the text; this looks like it needs an explicit maintainer call, not an implicit one buried in a source comment.
Practical risk either way: an agent that gets a divergence report, then resumes with a different -e/env value than the original run used (accidentally, or "helpfully" auto-corrected), gets no signal that downstream step behavior (e.g. fill ${TEXT}, open --launch-url ${URL}) changed — the plan-digest match gives a false sense that "nothing changed." Recommend at minimum: call this out explicitly in --help replay/--help workflow ("--plan-digest does not pin -e/shell-env variable values — only the script text and structural expansion"), so it's a documented caller responsibility rather than a silent gap.
3. INFO — comment-only edits correctly bump the digest (verified, matches spec)
canonicalizeAction includes source: { path, line }, and actionLines are literal physical file line numbers (script.ts: actionLines.push(index + 1)), so inserting a comment/blank line above an action shifts its digest. Live-reproduced: adding one comment line then resuming with the old digest → rejected INVALID_ARGS, zero device-action events logged (only request.started/request.finished, 1ms). This matches the ADR's explicit inclusion of "source provenance" in the digest — correct, if occasionally surprising (a purely cosmetic edit forces a fresh full replay). Worth a one-line help-text mention, not a defect.
4. Verified sound — resume preflight honesty (axis 2)
resolveReplayEntryIndex/validateReplayResumeRequest/evaluateReplayResumePreflight(session-replay-runtime.ts, session-replay-resume.ts) run entirely before the loop starts; the loop only begins atentryIndex.valueafterentryIndex.okis true. Live-verified: rejections (digest mismatch) produce zero device-action log entries.- Out-of-range
--from, control-flow-interior skip/target (retry/maestroRunFlowWhen), and outputEnv-producing skipped steps (MaestrorunScript) are all rejectedINVALID_ARGSpre-action — code-verified againstsession-replay-resume.ts. test --fromrejected at both layers as claimed: CLI-schema (testCliSchema.allowedFlagsincommands/replay/index.tsomitsreplayFrom/replayPlanDigest) and daemon (handleSessionReplayCommands). Both live-verified — CLI layer:"Flags --from, --plan-digest are not supported for command test."- Live end-to-end resume: broke a step, got
resume: {allowed:true, from:4, planDigest:...}, resumed with--from 5 --plan-digest <sha>after manually completing the equivalent step live →replayed: 2(only the tail of a 6-step plan executed; confirmed via session event log that noopen/earlierwaitactions re-ran).
5. Verified sound — retirement (axis 3)
writeReplayScriptfully deleted; grepped the whole repo (including dynamic-import patterns) — zero remaining callers outside a comment inscript.test.ts.healReplayAction's retry-and-rewrite arm removed cleanly;collectReplaySelectorCandidatesretained and still the sole source for rankedsuggestionsper decision 1.replayUpdatestays wired on the wire shape (client → CLI → daemon request), as the ADR requires ("kept... regardless of level" pre-retirement / "no-op... at any level" post), but is fully inert inrunReplayScriptFile— theshouldUpdatebranch and its snapshot/rewrite arm are gone outright, not merely short-circuited. No residual fs-write path reachable from replay.
6. Verified sound — digest canonicality (axis 1 b/d)
Include-file content changes and iOS-vs-Android runs both change the digest, by construction (action content flows through canonicalizeAction; metadata.platform is a top-level digest field) — matches plan-digest.test.ts coverage.
7. INFORMATIONAL — merge-ordering collision with #1209
#1209 (target-binding verification enforcement) and this PR both substantially rewrite the same loop body in session-replay-runtime.ts:
- Textual: #1209 moves
buildReplayDivergenceFailureResponseout tosession-replay-divergence.tsand wraps each dispatch in a newverifyReplayActionTargetpre-check; this PR changes the loop's start index (entryIndex.value) and removes thehealReplayActionbranch but leavesbuildReplayDivergenceFailureResponselocal. Whoever rebases second has a real (not just line-noise) merge here. - Semantic gap, more important than the textual conflict: #1209's
session-replay-target-verification.tscurrently hardcodesresume: REPLAY_DIVERGENCE_RESUME_NOT_SUPPORTED/{ allowed: false, reason: 'resume not yet supported' }on every target-binding divergence it builds (written before this PR's resume machinery existed). Post-merge, target-binding divergences (selector-miss/identity-mismatch/identity-unverifiable) would keep reportingresume.allowed:falsewith a stale reason even when resume from that step is actually safe, unless whoever merges second explicitly wires this PR'sbuildReplayDivergenceResumeinto #1209's target-verification path. Recommend landing this PR (#1211) first — smaller footprint on the shared loop — then rebasing #1209 to consumebuildReplayDivergenceResume.
Gates
pnpm check:tooling,pnpm check:fallow: green in the scratch worktree.pnpm test:unit: 401 files / 3732 tests green.- Targeted resume/digest/retirement suites: all green (93 tests across
plan-digest.test.ts,session-replay-resume.test.ts,session-replay-runtime.test.ts,session-replay.test.ts,commands/replay/index.test.ts,script.test.ts). gh pr checks 1211: red — see finding 1.
No paragraph-long workaround-justification comments found in the diff — the added comments are scoped ADR-reference architecture notes, consistent with repo convention.
…inner against the verified member Coordinator addition to step 4 (PR #1209): pre-action verification resolves without dispatch's occlusion-filtering/visibility-preferring guards, so verification's winner W could differ from the node dispatch actually acts on — verified-but-taps-a-different-element, or a false identity-mismatch. Close the split with a cheap cross-check at dispatch's own resolution: - Verified outcomes now carry the verified member's normalized local identity (`ReplayVerifiedTargetGuard`: readNodeLocalIdentity(winner) + verification's matchCount). The replay loop threads it onto the dispatch request as `internal.replayTargetGuard`. - Interaction handlers (press/click/longpress/fill in interaction-touch.ts, get in selector-runtime.ts) pass it into command options as `expectedResolvedTarget`, and a guarded dispatch skips the direct-iOS selector fast path and the native-ref tapTarget/fillTarget fast paths — the guard needs the runtime tree resolution to have a node to check. - `assertExpectedResolvedTarget` (resolution.ts) compares the resolution winner's identity — PRE-promotion (hittable-ancestor promotion deliberately retargets to the same element's actionable container) and PRE-action — against the verified identity, and refuses with `details.reason: 'replay_target_guard_mismatch'` on a mismatch. Enforced on the selector path, the ref path, and get's ref/selector reads. - The replay loop converts that refusal into an identity-mismatch target-binding divergence (`buildReplayTargetGuardMismatchResponse`): classification == kind, matchCount from verification's recorded-selector resolution (present, per the rule), recorded/observed identities, field-level mismatches, fresh screen observation — via the same bounding/sanitizing builder as every other target-binding divergence (finalize refactored into module-level buildTargetBindingDivergenceResponse for the two callers). - The identity reader moved to src/replay/target-identity-node.ts (readNodeLocalIdentity + localIdentitiesEqual + the reason marker) so the commands and daemon layers share it without a layering back-edge; session-target-evidence.ts's boundedLocalIdentity is now an alias. The guard is replay-only by construction: `internal.replayTargetGuard` is set exclusively by the replay step loop for annotated verified actions; live interactive commands never carry it. PR #1211 coordination: buildTargetBindingDivergenceResponse is the single resume site for target-binding divergences, marked as the step-5 wiring point — after #1211 merges, replace the not-supported stub with the real computed resume (pre-action divergences typically allow same-step resume) plus a test. Kept as a small follow-up by construction. Tests: split-resolver fixture (covered deeper A vs visible sibling B — verification verifies A, dispatch resolves B) proving the guard refuses pre-action with zero backend taps, the matching-identity pass-through, the ref-fast-path forcing, guard threading on the dispatch request (and its absence for unannotated actions), and end-to-end conversion of a guard mismatch into an identity-mismatch divergence with matchCount present.
…inner against the verified member Coordinator addition to step 4 (PR #1209): pre-action verification resolves without dispatch's occlusion-filtering/visibility-preferring guards, so verification's winner W could differ from the node dispatch actually acts on — verified-but-taps-a-different-element, or a false identity-mismatch. Close the split with a cheap cross-check at dispatch's own resolution: - Verified outcomes now carry the verified member's normalized local identity (`ReplayVerifiedTargetGuard`: readNodeLocalIdentity(winner) + verification's matchCount). The replay loop threads it onto the dispatch request as `internal.replayTargetGuard`. - Interaction handlers (press/click/longpress/fill in interaction-touch.ts, get in selector-runtime.ts) pass it into command options as `expectedResolvedTarget`, and a guarded dispatch skips the direct-iOS selector fast path and the native-ref tapTarget/fillTarget fast paths — the guard needs the runtime tree resolution to have a node to check. - `assertExpectedResolvedTarget` (resolution.ts) compares the resolution winner's identity — PRE-promotion (hittable-ancestor promotion deliberately retargets to the same element's actionable container) and PRE-action — against the verified identity, and refuses with `details.reason: 'replay_target_guard_mismatch'` on a mismatch. Enforced on the selector path, the ref path, and get's ref/selector reads. - The replay loop converts that refusal into an identity-mismatch target-binding divergence (`buildReplayTargetGuardMismatchResponse`): classification == kind, matchCount from verification's recorded-selector resolution (present, per the rule), recorded/observed identities, field-level mismatches, fresh screen observation — via the same bounding/sanitizing builder as every other target-binding divergence (finalize refactored into module-level buildTargetBindingDivergenceResponse for the two callers). - The identity reader moved to src/replay/target-identity-node.ts (readNodeLocalIdentity + localIdentitiesEqual + the reason marker) so the commands and daemon layers share it without a layering back-edge; session-target-evidence.ts's boundedLocalIdentity is now an alias. The guard is replay-only by construction: `internal.replayTargetGuard` is set exclusively by the replay step loop for annotated verified actions; live interactive commands never carry it. PR #1211 coordination: buildTargetBindingDivergenceResponse is the single resume site for target-binding divergences, marked as the step-5 wiring point — after #1211 merges, replace the not-supported stub with the real computed resume (pre-action divergences typically allow same-step resume) plus a test. Kept as a small follow-up by construction. Tests: split-resolver fixture (covered deeper A vs visible sibling B — verification verifies A, dispatch resolves B) proving the guard refuses pre-action with zero backend taps, the matching-identity pass-through, the ref-fast-path forcing, guard threading on the dispatch request (and its absence for unannotated actions), and end-to-end conversion of a guard mismatch into an identity-mismatch divergence with matchCount present.
|
Review at
Please rebase first, then address these on the new architecture and request re-review on the resulting head. |
…ment
Step 5 (decision 4, resume): replay --from <n> --plan-digest <sha256>
resumes at a 1-based plan step, skipping 1..n-1 without executing them.
Every divergence report now carries a real resume object (allowed, from,
planDigest, reason?) computed by a preflight that rejects INVALID_ARGS
before any action when: the plan digest no longer matches the current
script (edits/includes/platform-conditioned expansion), --from is out of
range, a skipped step can produce outputEnv values, or the skipped range
or resume target is runtime control flow (retry/runFlow.when — these are
single plan entries, never individually addressable). `test` rejects
--from/--plan-digest both at the CLI-schema layer and at the daemon
dispatch layer (the original command name is only visible before test
rewrites its nested request to `command: 'replay'`).
New modules: src/replay/plan-digest.ts (canonical SHA-256 plan digest)
and src/daemon/handlers/session-replay-resume.ts (preflight + the
report's resume object), kept out of src/replay/ to avoid a
replay<->compat import cycle.
Step 6 (decision 1, retirement): --update/-u no longer rewrites .ad
files. The ADR mandates a no-op, not an error or flag removal: --update
now runs identically to a plain replay and returns the same bounded
suggestions every divergence already carries. Removed: healReplayAction's
retry-and-rewrite arm and its exclusive helpers (collectReplaySelectorCandidates
stays — decision 1's suggestions still use it), the write call from the
runtime loop, and the env/${VAR}-interpolation/compat-flow refusal guards
that existed only to protect that rewrite. writeReplayScript itself keeps
its own round-trip tests but is otherwise unused now; deleted after the
production-exports gate flagged it as dead.
Docs: cli-help.ts workflow topic + --update/--from flag help, AGENTS.md
selector pipeline note, maestro-compat-debt-map.md, website replay-e2e.md
and commands.md updated for the retired rewrite and the new resume loop.
The Integration Tests job's architecture-progress gate
(test:integration:progress:check) requires every public CLI flag to be
classified; --from/--plan-digest (replayFrom/replayPlanDigest) were
unclassified. Classify them as device-observable workflow flags and add
real provider-backed coverage to the Android lifecycle scenario: a full
replay diverges on a missing selector, the report's resume object is
asserted (allowed/from/planDigest), and resuming at the next index
replays only the tail. Also refresh the stale replayUpdate reason
("selector-healing replay update" -> the retired no-op).
40c8b2e to
931f914
Compare
|
Rebased onto origin/main (now includes #1193 selector-resolution disclosure and #1210 architecture-ownership consolidation) — new head is 931f914 (was 40c8b2e; same two commits, rebased). Non-trivial resolutions, both sides preserved: (1) #1210 deleted |
|
Re-review of rebased head
The rebase fixed ownership conflicts only; it did not address these correctness/guidance/locality findings. |
|
Fixer re-review at |
|
One final P2 guidance inconsistency remains at |
|
Final P2 wording issue at |
|
Final re-review at
|
…R 0012 + workflow help) Settled decision from the PR #1211 re-review (maintainer-approved): interpolated ${VAR}/--env/AD_VAR_* VALUES are deliberately NOT part of the resume plan digest. Substitution happens after the digest is computed over the still-unsubstituted ${VAR} text, so re-running the same script with different variable values keeps the same digest and stays resumable — supplying the right values on resume is the caller's responsibility. The digest still binds the script/includes, the effective --platform/--target, and per-action runtime hints + target-v1 identity. Documented in ADR 0012 decision 4 and the `help workflow` resume topic.
|
New head 747c58b: I rebased onto the maintainer's own fixes for the six blockers (plan-digest completeness, module/test splits, skillgym, and the three CI-regression fixes — all authored by @thymikee). My commit on top adds only the settled |
…inner against the verified member Coordinator addition to step 4 (PR #1209): pre-action verification resolves without dispatch's occlusion-filtering/visibility-preferring guards, so verification's winner W could differ from the node dispatch actually acts on — verified-but-taps-a-different-element, or a false identity-mismatch. Close the split with a cheap cross-check at dispatch's own resolution: - Verified outcomes now carry the verified member's normalized local identity (`ReplayVerifiedTargetGuard`: readNodeLocalIdentity(winner) + verification's matchCount). The replay loop threads it onto the dispatch request as `internal.replayTargetGuard`. - Interaction handlers (press/click/longpress/fill in interaction-touch.ts, get in selector-runtime.ts) pass it into command options as `expectedResolvedTarget`, and a guarded dispatch skips the direct-iOS selector fast path and the native-ref tapTarget/fillTarget fast paths — the guard needs the runtime tree resolution to have a node to check. - `assertExpectedResolvedTarget` (resolution.ts) compares the resolution winner's identity — PRE-promotion (hittable-ancestor promotion deliberately retargets to the same element's actionable container) and PRE-action — against the verified identity, and refuses with `details.reason: 'replay_target_guard_mismatch'` on a mismatch. Enforced on the selector path, the ref path, and get's ref/selector reads. - The replay loop converts that refusal into an identity-mismatch target-binding divergence (`buildReplayTargetGuardMismatchResponse`): classification == kind, matchCount from verification's recorded-selector resolution (present, per the rule), recorded/observed identities, field-level mismatches, fresh screen observation — via the same bounding/sanitizing builder as every other target-binding divergence (finalize refactored into module-level buildTargetBindingDivergenceResponse for the two callers). - The identity reader moved to src/replay/target-identity-node.ts (readNodeLocalIdentity + localIdentitiesEqual + the reason marker) so the commands and daemon layers share it without a layering back-edge; session-target-evidence.ts's boundedLocalIdentity is now an alias. The guard is replay-only by construction: `internal.replayTargetGuard` is set exclusively by the replay step loop for annotated verified actions; live interactive commands never carry it. PR #1211 coordination: buildTargetBindingDivergenceResponse is the single resume site for target-binding divergences, marked as the step-5 wiring point — after #1211 merges, replace the not-supported stub with the real computed resume (pre-action divergences typically allow same-step resume) plus a test. Kept as a small follow-up by construction. Tests: split-resolver fixture (covered deeper A vs visible sibling B — verification verifies A, dispatch resolves B) proving the guard refuses pre-action with zero backend taps, the matching-identity pass-through, the ref-fast-path forcing, guard threading on the dispatch request (and its absence for unannotated actions), and end-to-end conversion of a guard mismatch into an identity-mismatch divergence with matchCount present.
Live resume-loop evidence (head 747c58b)Built Note: the PR head has since advanced to Flow exercised: 1. Record (
|
…lution guard + step-5 resume wiring Rebuilt onto current main (#1210 selector relocation, #1211 resume + --update retirement, #1213, #1215) as one coherent commit — the prior 4-commit history collided structurally with #1211's own session-replay-runtime split. Step 4 (decision 3, enforcement): every annotated resolved-target replay/test action is verified against a fresh pre-action snapshot BEFORE dispatch (verifyReplayActionTarget → classifyReplayTarget, decision 3's six paths). Only a verified outcome sends the action; selector-miss / identity-mismatch / identity-unverifiable each return a complete target-binding REPLAY_DIVERGENCE built from its own capture, with targetBinding {classification==kind, matchCount per the presence rule, recorded/observed, mismatches, candidates} through #1211's shared failure-response + bounding/sanitizer pipeline. Post-resolution guard: a verified outcome mints internal.replayTargetGuard (the verified member's identity); dispatch's own resolution (occlusion/ visibility guards verification does not replicate) must land on the SAME element or assertExpectedResolvedTarget refuses pre-action, which the loop converts to an identity-mismatch divergence. Guarded dispatch skips the direct-iOS and native-ref fast paths so a tree node exists to check. Step-5 resume wiring (this rebase): the target-binding divergence resume stub is replaced with #1211's buildReplayDivergenceResume — a pre-action divergence resumes AT the failed step (from = step index, real SHA-256 planDigest, allowed unless preflight rejects the skipped range). buildTargetBindingDivergenceResponse is the single resume site; planActions/planDigest are threaded from the loop. --update healing is retired by #1211, so the in-branch "verify healed action" work is dropped (its module and test removed as moot). Tests: classification (13), guard (4), token (5), runtime end-to-end (9, incl. old-script pass-through, guard threading + mismatch conversion, and a real computed resume assertion), plus target-binding wire tests in divergence.test.ts (classification==kind, matchCount presence, overflow-fallback targetBinding, computed-resume-not-stub, text report). Also repoints daemon-http-disconnect.test.ts already handled upstream by #1215.
Summary
Implements ADR-0012 ("Interactive Replay") migration step 5 (decision 4, replay resume) and step 6 (decision 1,
--updateretirement). Depends only on step 2 (#1197, merged) per the migration plan's ordering.Step 5 —
replay --from/--plan-digestresumereplay --from <n> --plan-digest <sha256>resumes at 1-based plan stepn, skipping1..n-1without executing them.testrejects both flags — at the CLI-schema layer (replayCliSchema.allowedFlagsonly, not sharedREPLAY_FLAGS) and, in case a caller reaches the daemon directly, athandleSessionReplayCommands(the only place that still sees the originaltestcommand name before it's rewritten to a nestedreplayrequest).REPLAY_DIVERGENCEreport now carries a realresume: { allowed, from, planDigest, reason? }(replacing the old always-{allowed:false}stub from feat(replay): ADR 0012 migration step 2 — structured replay divergence report #1197).planDigestis a canonical SHA-256 over the fully expanded plan (command, positionals, flags, control-flow shape, source provenance) — newsrc/replay/plan-digest.ts.src/daemon/handlers/session-replay-resume.ts) rejectsINVALID_ARGSbefore any device action when: the plan digest no longer matches the current script (edit/include/platform-conditioned expansion changed),--fromis out of range, a skipped step can produceoutputEnvvalues a later step might consume (MaestrorunScript), or the skipped range/target is runtime control flow (retry/runFlow.when— these execute dynamically and are always a single plan entry, never individually addressable by index).plan-digest.tslives insrc/replay/(type-only import ofSessionAction, matching the existingscript.tspattern); the resume preflight (needsMAESTRO_RUNTIME_COMMAND, a value import) lives insrc/daemon/handlers/instead, to avoid areplay <-> compatimport cycle (compat/replay-input.tsalready imports fromreplay/).session-replay-runtime.tschanges are minimal and localized per the coordination note: digest computation + entry-index preflight before the loop,entryIndex.valueas the loop start, andplanActions/planDigestthreaded into the existing failure branch.Step 6 —
--updateretirementThe ADR text (decision 1) mandates a no-op, not an error or flag removal: "after retirement,
--updateat any level performs no rewrite and returns the same bounded suggestions object." Since #1197 already madesuggestionsunconditional on every divergence,--updatenow runs identically to a plain replay.healReplayAction's retry-and-rewrite arm and its exclusive helpers fromsession-replay-heal.ts; keptcollectReplaySelectorCandidates(still powers decision 1's ranked suggestions).writeReplayScriptcall from the runtime loop, and the env-directive/${VAR}-interpolation/compat-flow refusal guards that existed only to protect that rewrite (a Maestro flow,envdirectives, and interpolated selectors are now all fine under--update— there's nothing to protect).writeReplayScriptitself (general.adserializer, kept its own round-trip test coverage) ended up with zero remaining callers once the update path stopped invoking it — thecheck:production-exportsgate flagged it as dead, so it's deleted; its tests were rewritten to exercise the underlyingformatPortableActionLine/formatTargetAnnotationLinesprimitives directly (still live, shared with the session recorder's writer).healedstays0on every response (kept on the wire shape for existing test-summary/JUnit reporters).Docs
cli-help.ts(workflow topic +--update/--fromflag descriptions),AGENTS.mdselector-pipeline note,docs/maestro-compat-debt-map.md,website/docs/docs/replay-e2e.md(new "Replay divergence and resume" section replacing the old before/after rewrite examples),website/docs/docs/commands.md.ADR-vs-code note
None encountered — decision 1 and decision 4 read unambiguously against the current code; the one judgment call (no-op vs. error vs. removal for
--update) is directly answered by the ADR's own text quoted above.Test plan
pnpm check:tooling(lint, typecheck, layering, production-exports, mcp-metadata, build, bundle-owner-files) — greenpnpm check:fallow(complexity/dead-code audit against merge-base) — green (one complexity finding onresolveReplayEntryIndexfixed by extracting validators)pnpm test:unit(unit-core + android-adb) — green; 2 unrelated pre-existing flaky timeouts under full-suite CPU contention (client-metro.test.ts,daemon-client.test.ts), confirmed passing in isolationpnpm test:smoke— greensrc/replay/__tests__/plan-digest.test.ts,src/daemon/handlers/__tests__/session-replay-resume.test.ts(preflight unit tests: control-flow skip/target, outputEnv,from===1)session-replay-runtime.test.ts: resume happy path (skip-without-invoking,replayedcount), flag-pairing/out-of-range/stale-digest rejection, retry-wrapped control-flow rejection end-to-end,--updateno-write regression +healed:0+ env/interpolation guard removal, migrated JSON/malformed-.adparse-error tests from the deletedreplay-heal.test.tssession-replay.test.ts:test --from/test --plan-digestdaemon-layer rejectioncommands/replay/index.test.ts:resumeFrom/resumePlanDigestCLI-reader/daemon-writer mapping, confirmed absent fromtesthelp replay/help test/help workflowrender correctly;test --fromrejected pre-dispatch withINVALID_ARGS🤖 Generated with Claude Code