@enricai/barnacle 1.12.54 → 1.12.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/api/schemas/common.d.ts +28 -0
  2. package/dist/api/schemas/common.d.ts.map +1 -1
  3. package/dist/api/schemas/common.js +51 -0
  4. package/dist/api/schemas/common.js.map +1 -1
  5. package/dist/plugins/config-plugin.d.ts +8 -0
  6. package/dist/plugins/config-plugin.d.ts.map +1 -1
  7. package/dist/plugins/config-plugin.js +56 -3
  8. package/dist/plugins/config-plugin.js.map +1 -1
  9. package/dist/plugins/json-schema-to-zod.d.ts +6 -3
  10. package/dist/plugins/json-schema-to-zod.d.ts.map +1 -1
  11. package/dist/plugins/json-schema-to-zod.js +18 -6
  12. package/dist/plugins/json-schema-to-zod.js.map +1 -1
  13. package/dist/plugins/loader.d.ts.map +1 -1
  14. package/dist/plugins/loader.js +7 -1
  15. package/dist/plugins/loader.js.map +1 -1
  16. package/dist/recon/capture-filters.d.ts +2 -55
  17. package/dist/recon/capture-filters.d.ts.map +1 -1
  18. package/dist/recon/capture-filters.js +152 -4
  19. package/dist/recon/capture-filters.js.map +1 -1
  20. package/dist/recon/load-value-constraints.d.ts +22 -0
  21. package/dist/recon/load-value-constraints.d.ts.map +1 -0
  22. package/dist/recon/load-value-constraints.js +61 -0
  23. package/dist/recon/load-value-constraints.js.map +1 -0
  24. package/dist/recon/value-constraints.d.ts +46 -0
  25. package/dist/recon/value-constraints.d.ts.map +1 -0
  26. package/dist/recon/value-constraints.js +31 -0
  27. package/dist/recon/value-constraints.js.map +1 -0
  28. package/dist/scraper/errors.d.ts +12 -0
  29. package/dist/scraper/errors.d.ts.map +1 -1
  30. package/dist/scraper/errors.js +21 -1
  31. package/dist/scraper/errors.js.map +1 -1
  32. package/dist/scraper/flow-runner.d.ts +87 -23
  33. package/dist/scraper/flow-runner.d.ts.map +1 -1
  34. package/dist/scraper/flow-runner.js +417 -71
  35. package/dist/scraper/flow-runner.js.map +1 -1
  36. package/dist/scraper/http-client.d.ts +11 -0
  37. package/dist/scraper/http-client.d.ts.map +1 -1
  38. package/dist/scraper/http-client.js +96 -2
  39. package/dist/scraper/http-client.js.map +1 -1
  40. package/dist/scraper/phantom-click.d.ts +2 -2
  41. package/dist/scraper/phantom-click.d.ts.map +1 -1
  42. package/dist/scraper/phantom-click.js +6 -5
  43. package/dist/scraper/phantom-click.js.map +1 -1
  44. package/dist/scraper/submit-control.d.ts +11 -6
  45. package/dist/scraper/submit-control.d.ts.map +1 -1
  46. package/dist/scraper/submit-control.js +38 -7
  47. package/dist/scraper/submit-control.js.map +1 -1
  48. package/dist/scripts/recon-browser.d.ts +45 -8
  49. package/dist/scripts/recon-browser.d.ts.map +1 -1
  50. package/dist/scripts/recon-browser.js +145 -16
  51. package/dist/scripts/recon-browser.js.map +1 -1
  52. package/dist/scripts/recon-generate.d.ts +65 -4
  53. package/dist/scripts/recon-generate.d.ts.map +1 -1
  54. package/dist/scripts/recon-generate.js +387 -49
  55. package/dist/scripts/recon-generate.js.map +1 -1
  56. package/dist/site-plugin.d.ts +9 -0
  57. package/dist/site-plugin.d.ts.map +1 -1
  58. package/package.json +1 -1
@@ -1207,29 +1207,42 @@ function isCheckboxOrRadioIntentStep(instruction) {
1207
1207
  * Whether `snapshotPage` should build the per-element selection baseline
1208
1208
  * (`StepSnapshot.selectionStateByXpath`) for this step — i.e. whether
1209
1209
  * `verifyDomEffect`'s element-scoped click read-back is allowed to credit it.
1210
- * True ONLY for a field-answer/selection step: a submit, a final, or an advance
1211
- * step must be verified by a real network/URL transition, so its own
1210
+ * True ONLY for a field-answer/selection step: a submit or an advance step
1211
+ * must be verified by a real network/URL transition, so its own
1212
1212
  * self-toggling button (a submit flipping to a loading/pressed class, a "Next"
1213
1213
  * flipping `aria-pressed`) must never earn an element-scoped credit — matching
1214
- * the `!submit`/`!final`/`!advance` exclusions the former
1215
- * `isClickStateToggleVerified` gate enforced. Pure + exported so the gate the
1214
+ * the `!submit`/`!advance` exclusions the former `isClickStateToggleVerified`
1215
+ * gate enforced. A benign final step (one that is NOT itself the flagged
1216
+ * submit step) still captures — `flowHasSubmitSemantics` describes the FLOW,
1217
+ * not this step, so it must not veto capture on its own; `submitStep` alone
1218
+ * (authoritative per {@link flowHasSubmitSemantics}) identifies the step that
1219
+ * actually needs network/URL verification. Pure + exported so the gate the
1216
1220
  * cascade depends on is unit-testable, not buried in `executeStepWithHealing`.
1217
1221
  */
1218
1222
  function shouldCaptureSelectionState(params) {
1219
- const { step, isFinalStep, submitStep, flowHasSubmitSemantics } = params;
1220
- return !(submitStep || (isFinalStep && flowHasSubmitSemantics) || isAdvanceStep(step));
1221
- }
1222
- /**
1223
- * Whether a flow has ANY submit semantics at all — a step flagged
1224
- * `submitStep: true`, a `submitEndpointPattern`, or `requireSubmitEndpointMatch`.
1225
- * A read-only flow (none of the three) has no submit shape anywhere, so its
1226
- * final step is an ordinary read/click, not a submit. Pure + exported so
1227
- * callers can stop inferring submit-shape from `isFinalStep` alone on flows
1228
- * that never declared a submit.
1223
+ const { step, submitStep } = params;
1224
+ return !(submitStep || isAdvanceStep(step));
1225
+ }
1226
+ /**
1227
+ * Whether the flow's FINAL step carries submit semantics. A read-only flow
1228
+ * (no step flagged `submitStep: true`, no `submitEndpointPattern`, no
1229
+ * `requireSubmitEndpointMatch`) has no submit shape anywhere, so its final
1230
+ * step is an ordinary read/click. When some step IS explicitly flagged
1231
+ * `submitStep: true`, that flag is authoritative over inference — the final
1232
+ * step only counts as submit-shaped if it is itself the flagged step, not
1233
+ * merely because an earlier, unrelated step claims the submit role. Only
1234
+ * when NO step carries an explicit flag do we fall back to inferring
1235
+ * submit-shape onto the final step from `submitEndpointPattern` /
1236
+ * `requireSubmitEndpointMatch` (the documented fallback for self-heal-
1237
+ * appended final steps that never got the flag set). Pure + exported so
1238
+ * callers can stop inferring submit-shape from `isFinalStep` alone.
1229
1239
  */
1230
1240
  function flowHasSubmitSemantics(params) {
1231
1241
  const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
1232
- return (steps.some((s) => s.submitStep) || submitEndpointPattern !== null || requireSubmitEndpointMatch);
1242
+ const hasExplicitSubmitStep = steps.some((s) => s.submitStep);
1243
+ return hasExplicitSubmitStep
1244
+ ? (steps[steps.length - 1]?.submitStep ?? false)
1245
+ : submitEndpointPattern !== null || requireSubmitEndpointMatch;
1233
1246
  }
1234
1247
  /**
1235
1248
  * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
@@ -1471,10 +1484,13 @@ function isDomOnlyAdvanceVerified(params) {
1471
1484
  * (`VIEW_SWAP_MIN_BYTES` env var) lets a deployment lower the threshold
1472
1485
  * without regressing the shared default for other sites.
1473
1486
  *
1474
- * **Scope guards:** Excludes final/submit steps (those require real network
1475
- * per isSubmitRevealedInvalid + LLM submit judge) and advance-pattern steps
1476
- * (those require real transition per isDomOnlyAdvanceVerified/isAdvanceStalled
1477
- * to avoid wizard-ATS autosave-vs-transition ambiguity).
1487
+ * **Scope guards:** Excludes steps explicitly flagged `submitStep: true`
1488
+ * (those require real network per isSubmitRevealedInvalid + LLM submit
1489
+ * judge — trusting only the step's own explicit flag, never the flow-level
1490
+ * `flowHasSubmitSemantics` inference: an unflagged final step may genuinely
1491
+ * be a legitimate client-side view swap) and advance-pattern steps (those
1492
+ * require real transition per isDomOnlyAdvanceVerified/isAdvanceStalled to
1493
+ * avoid wizard-ATS autosave-vs-transition ambiguity).
1478
1494
  *
1479
1495
  * **Small-delta reveal credit:** a sub-section unhiding within an
1480
1496
  * already-loaded page (e.g. a validation-triggered "Work History requirement"
@@ -1505,10 +1521,16 @@ function isDomOnlyAdvanceVerified(params) {
1505
1521
  function isClickViewSwapVerified(params) {
1506
1522
  const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
1507
1523
  const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
1508
- const { resolvedAction, isFinalStep, submitStep, flowHasSubmitSemantics, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
1524
+ const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
1509
1525
  if (resolvedAction?.method !== "click")
1510
1526
  return false;
1511
- if (submitStep || (isFinalStep && flowHasSubmitSemantics))
1527
+ // Only the step's own explicit submitStep flag identifies the step that
1528
+ // actually needs network/URL verification — mirrors the submit-judge
1529
+ // gate's hasSubmitTransitionSignal discipline (see 5763ac2). Inferring
1530
+ // submit-shape from isFinalStep && flowHasSubmitSemantics falsely vetoes
1531
+ // an inferred final same-page toggle step that was never going to receive
1532
+ // a real network/URL transition, leaving it structurally unverifiable.
1533
+ if (submitStep)
1512
1534
  return false;
1513
1535
  if (isAdvanceWithPattern)
1514
1536
  return false;
@@ -1516,9 +1538,9 @@ function isClickViewSwapVerified(params) {
1516
1538
  return false;
1517
1539
  if (invalidMarkerDelta > 0)
1518
1540
  return false;
1519
- if (bytesDelta >= VIEW_SWAP_MIN_BYTES)
1541
+ if (Math.abs(bytesDelta) >= VIEW_SWAP_MIN_BYTES)
1520
1542
  return true;
1521
- return textChanged && bytesDelta >= VIEW_SWAP_REVEAL_MIN_BYTES;
1543
+ return textChanged && Math.abs(bytesDelta) >= VIEW_SWAP_REVEAL_MIN_BYTES;
1522
1544
  }
1523
1545
  /**
1524
1546
  * Reads the "N settings/items selected" running count that multi-select wizard
@@ -4564,6 +4586,28 @@ async function waitForCaptchaNavigation(params) {
4564
4586
  }
4565
4587
  return false;
4566
4588
  }
4589
+ /**
4590
+ * Distinguishes a navigating evaluate's expected context-teardown rejection
4591
+ * (the callback/submit dispatch it wraps synchronously navigated the frame,
4592
+ * tearing down the execution context before a return value marshals) from a
4593
+ * genuine in-page exception — a thrown `TypeError`, a rejected fetch, or any
4594
+ * other real failure inside the site's own callback/submit logic. Both are
4595
+ * indistinguishable to a blanket `.catch(() => undefined)`, which is exactly
4596
+ * why a genuinely thrown-and-swallowed exception during the callback invoke
4597
+ * used to log `callbackDiscovered=true`/`injected=true` (both computed
4598
+ * BEFORE the invoke) while never actually dispatching anything. Only a
4599
+ * context-teardown rejection is safe to discard silently; this is what lets
4600
+ * callers keep doing that for the first case while surfacing the second.
4601
+ */
4602
+ function isNavigatingEvaluateRejection(error) {
4603
+ const message = (0, errors_1.toErrorMessage)(error).toLowerCase();
4604
+ return (message.includes("execution context was destroyed") ||
4605
+ message.includes("execution context is not available") ||
4606
+ message.includes("cannot find context with specified id") ||
4607
+ message.includes("target closed") ||
4608
+ message.includes("frame was detached") ||
4609
+ message.includes("no frame for given id found"));
4610
+ }
4567
4611
  /**
4568
4612
  * Shared in-page discovery of the widget's callback, used identically by
4569
4613
  * `injectCaptchaTokenAndSubmit`'s precheck (to decide the branch) and its
@@ -4616,6 +4660,10 @@ function setResponseFieldAndInvokeCallbackExprSrc(responseField, token) {
4616
4660
  } else if (field) {
4617
4661
  field.value = token;
4618
4662
  }
4663
+ if (field) {
4664
+ field.dispatchEvent(new Event("input", { bubbles: true }));
4665
+ field.dispatchEvent(new Event("change", { bubbles: true }));
4666
+ }
4619
4667
  found.invoke(token);
4620
4668
  `;
4621
4669
  }
@@ -4707,8 +4755,8 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4707
4755
  // case: it's an anonymous closure living in the registry, not a named
4708
4756
  // `window` global, so it cannot be marshalled back from the precheck's
4709
4757
  // evaluate call.
4710
- await target.evaluate(invokeCallbackExpr).catch(() => undefined);
4711
- return { injected, hasForm, callbackDiscovered };
4758
+ const callbackInvokeError = await target.evaluate(invokeCallbackExpr).then(() => undefined, (err) => (isNavigatingEvaluateRejection(err) ? undefined : (0, errors_1.toErrorMessage)(err)));
4759
+ return { injected, hasForm, callbackDiscovered, callbackInvokeError };
4712
4760
  }
4713
4761
  // Belt-and-suspenders: this frame missed session.ts's page-init-script
4714
4762
  // install and bugfix-001's per-frame attach install (it either attached
@@ -4739,8 +4787,8 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4739
4787
  // the non-late invoke above: the callback is free to submit the form
4740
4788
  // synchronously off the field value that's now present, tearing down the
4741
4789
  // execution context before a return value marshals.
4742
- await target.evaluate(invokeLateCallbackExpr).catch(() => undefined);
4743
- return { injected, hasForm, callbackDiscovered: true };
4790
+ const callbackInvokeError = await target.evaluate(invokeLateCallbackExpr).then(() => undefined, (err) => (isNavigatingEvaluateRejection(err) ? undefined : (0, errors_1.toErrorMessage)(err)));
4791
+ return { injected, hasForm, callbackDiscovered: true, callbackInvokeError };
4744
4792
  }
4745
4793
  const setValueExpr = `(() => {
4746
4794
  const responseField = ${JSON.stringify(responseField)};
@@ -4773,7 +4821,10 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4773
4821
  const dispatchChangeExpr = `(() => {
4774
4822
  const responseField = ${JSON.stringify(responseField)};
4775
4823
  const field = document.querySelector('[name="' + responseField + '"]');
4776
- if (field) field.dispatchEvent(new Event("change", { bubbles: true }));
4824
+ if (field) {
4825
+ field.dispatchEvent(new Event("input", { bubbles: true }));
4826
+ field.dispatchEvent(new Event("change", { bubbles: true }));
4827
+ }
4777
4828
  })()`;
4778
4829
  // Dispatching "change" can trigger a page's own submit-on-token callback,
4779
4830
  // which navigates the frame synchronously from within this call and tears
@@ -4788,24 +4839,123 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4788
4839
  * used only when the caller's own transition poll observed no advance after
4789
4840
  * the inject, so the widget's callback (if any) evidently didn't submit for
4790
4841
  * us. Kept separate from the inject primitive so the caller can gate its use
4791
- * on an observed transition rather than firing it unconditionally. Prefers
4792
- * `form.requestSubmit()` so any submit-event listener (including one that
4793
- * calls `preventDefault()` and drives its own submit logic) and native form
4794
- * validation still run, matching real-browser submit semantics; falls back
4795
- * to the bare `form.submit()` only when `requestSubmit` isn't available.
4796
- */
4797
- async function submitCaptchaGatedForm(target, responseField = "h-captcha-response") {
4842
+ * on an observed transition rather than firing it unconditionally.
4843
+ *
4844
+ * Dispatches a real, framework-observed click via the same
4845
+ * {@link buildRankSubmitCandidatesExpr}/{@link buildClickByDeepIndexExpr}
4846
+ * primitives the phantom-click cascade uses, rather than a synthetic
4847
+ * `form.requestSubmit()`/`form.submit()` call: on a React/SPA site the
4848
+ * submit button's `onClick` handler owns the actual submission (validation,
4849
+ * XHR/fetch dispatch, etc.), and `<form>`-level APIs never fire it, so
4850
+ * "success" here would silently produce zero site-host HTTP traffic for
4851
+ * {@link waitForCaptchaNavigation} to observe. Only when no submit-shaped
4852
+ * control can be found or clicked does this fall back to the form-level API
4853
+ * — a plain server-rendered form with no button-bound JS handler still needs
4854
+ * `requestSubmit()`/`submit()` to actually navigate.
4855
+ *
4856
+ * Resolves the form to submit by name first (the response field's own
4857
+ * closest form), then falls back to the sitekey-anchored form (or the sole
4858
+ * form on the page) the same way {@link injectCaptchaTokenAndSubmit} resolves
4859
+ * its target: an invisible/callback-only widget never creates a named
4860
+ * response field, so a named-field-only lookup here would silently no-op and
4861
+ * no site-host traffic would ever follow the clean callback. Resolves to
4862
+ * whether a form was actually found and acted on; a true no-op is reported
4863
+ * only when the page has no form at all to resolve.
4864
+ *
4865
+ * The click-dispatch and form-level submit evals both tolerate the expected
4866
+ * context-teardown rejection of a click/submit that navigated the frame —
4867
+ * see {@link isNavigatingEvaluateRejection} — but rethrow anything else, so a
4868
+ * genuine in-page exception during the dispatch propagates to the caller
4869
+ * instead of silently resolving as a successful submit.
4870
+ *
4871
+ * The ranked-candidate click is verified the same way the main cascade's
4872
+ * `deep-submit-locator` verifies its top pick (see `executeStepWithHealing`
4873
+ * at flow-runner.ts:10374-10428): a shadow-root/web-component control can
4874
+ * report `clicked: true` while wiring no real handler, so a bare
4875
+ * `clicked: true` here would let a phantom click masquerade as a resolved
4876
+ * submit and this fallback would return `true` with zero site-host traffic
4877
+ * ever following. When `verification` is supplied, snapshots pre/post via
4878
+ * `snapshotPage` and classifies with `classifyPhantomClick({isSubmitShapedStep:
4879
+ * true})`; a `phantom` verdict retries the runner-up candidate once (mirroring
4880
+ * the cascade's single runner-up retry) before falling through to the
4881
+ * form-level submit below. `verification` is optional so callers that only
4882
+ * need the non-polling, non-verifying original contract (and existing tests
4883
+ * exercising the rank/click primitives directly) are unaffected.
4884
+ */
4885
+ async function submitCaptchaGatedForm(target, responseField = "h-captcha-response", verification) {
4886
+ const findFormExpr = `(() => {
4887
+ const responseField = ${JSON.stringify(responseField)};
4888
+ const field = document.querySelector('[name="' + responseField + '"]');
4889
+ const fieldForm = field ? field.closest("form") : null;
4890
+ if (fieldForm) return true;
4891
+ const forms = Array.from(document.querySelectorAll("form"));
4892
+ const sitekeyForm = forms.find((candidate) => candidate.querySelector("[data-sitekey]")) ?? forms[0];
4893
+ return Boolean(sitekeyForm);
4894
+ })()`;
4895
+ const found = await target.evaluate(findFormExpr).catch(() => false);
4896
+ if (!found)
4897
+ return false;
4898
+ const rankResult = await target
4899
+ .evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)())
4900
+ .catch(() => []);
4901
+ const ranked = Array.isArray(rankResult) ? rankResult : [];
4902
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the length check
4903
+ const top = ranked.length > 0 ? ranked[0] : null;
4904
+ if (top) {
4905
+ const pre = verification
4906
+ ? await snapshotPage(target, verification.signalCounter, verification.page)
4907
+ : null;
4908
+ const clickResult = await target
4909
+ .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))
4910
+ .catch((err) => {
4911
+ if (isNavigatingEvaluateRejection(err))
4912
+ return { clicked: false };
4913
+ throw err;
4914
+ });
4915
+ if (clickResult.clicked) {
4916
+ if (!pre || !verification)
4917
+ return true;
4918
+ const post = await snapshotPage(target, verification.signalCounter, verification.page);
4919
+ const verdict = (0, phantom_click_1.classifyPhantomClick)({
4920
+ actResultSuccess: true,
4921
+ pre,
4922
+ post,
4923
+ isSubmitShapedStep: true,
4924
+ });
4925
+ if (verdict !== "phantom")
4926
+ return true;
4927
+ const runnerUp = ranked[1];
4928
+ if (runnerUp) {
4929
+ const runnerUpClickResult = await target
4930
+ .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
4931
+ .catch((err) => {
4932
+ if (isNavigatingEvaluateRejection(err))
4933
+ return { clicked: false };
4934
+ throw err;
4935
+ });
4936
+ if (runnerUpClickResult.clicked)
4937
+ return true;
4938
+ }
4939
+ }
4940
+ }
4798
4941
  const submitExpr = `(() => {
4799
4942
  const responseField = ${JSON.stringify(responseField)};
4800
4943
  const field = document.querySelector('[name="' + responseField + '"]');
4801
- const form = field ? field.closest("form") : null;
4944
+ const fieldForm = field ? field.closest("form") : null;
4945
+ const forms = Array.from(document.querySelectorAll("form"));
4946
+ const form = fieldForm ?? forms.find((candidate) => candidate.querySelector("[data-sitekey]")) ?? forms[0];
4802
4947
  if (form) (form.requestSubmit ? form.requestSubmit() : form.submit());
4803
4948
  })()`;
4804
4949
  // form.submit() navigates the frame synchronously, tearing down the execution
4805
4950
  // context before Runtime.evaluate can marshal a return value for this call —
4806
4951
  // that rejection is the expected outcome of a navigating evaluate, not a
4807
4952
  // real failure, so it's discarded here rather than awaited for a result.
4808
- await target.evaluate(submitExpr).catch(() => undefined);
4953
+ await target.evaluate(submitExpr).catch((err) => {
4954
+ if (isNavigatingEvaluateRejection(err))
4955
+ return undefined;
4956
+ throw err;
4957
+ });
4958
+ return true;
4809
4959
  }
4810
4960
  /**
4811
4961
  * Site-agnostic select primitive: answer a native `<select>` dropdown by
@@ -7759,7 +7909,7 @@ async function executeStepWithHealing(params) {
7759
7909
  // instead of silently falling through as if unverified-but-passing.
7760
7910
  const isSubmitOrFinalStep = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag);
7761
7911
  if (captchaGated && isSubmitOrFinalStep) {
7762
- const captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
7912
+ let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
7763
7913
  const sitekeyProbeExpr = `(() => {
7764
7914
  const el = document.querySelector("[data-sitekey]");
7765
7915
  if (!el) return { siteKey: null, isInvisible: false };
@@ -7787,26 +7937,56 @@ async function executeStepWithHealing(params) {
7787
7937
  // no-nav case is the same underlying race from the other side.
7788
7938
  for (let captchaAttempt = 1; captchaAttempt <= CAPTCHA_REGISTRY_RETRY_ATTEMPTS; captchaAttempt++) {
7789
7939
  if (captchaAttempt > 1) {
7940
+ // Re-resolve the target frame before every retry, mirroring the
7941
+ // main step loop's per-step `resolveFrameTarget` call: a
7942
+ // navigation or detach triggered by the prior attempt (its solve's
7943
+ // ~120s wait, its inject, or its submit) can leave `captchaTarget`
7944
+ // bound to a torn-down frame, and every evaluate below would
7945
+ // otherwise silently keep operating on that stale target for the
7946
+ // rest of the retries. A target with no declared frame selector
7947
+ // (main-frame flows) resolves this immediately with zero polling.
7948
+ captchaTarget = await (0, frame_target_1.resolveFrameTarget)(page, captchaTarget.frameSelector ?? captchaTarget.declaredFrameSelector ?? null);
7790
7949
  // Re-assert the capture install in the target frame before
7791
7950
  // retrying: a late-attaching registry may not exist yet on the
7792
7951
  // first attempt, and re-running this idempotent install gives a
7793
7952
  // widget that renders on demand another chance to be caught.
7794
7953
  await captchaTarget.evaluate((0, captcha_callback_capture_1.buildHcaptchaCallbackCaptureScript)()).catch(() => undefined);
7795
7954
  }
7796
- const solved = await (0, captcha_solver_1.solveCaptcha)({
7955
+ const solveResult = await (0, captcha_solver_1.solveCaptcha)({
7797
7956
  type: "hcaptcha",
7798
7957
  siteKey,
7799
7958
  pageUrl,
7800
7959
  isInvisible,
7801
7960
  userAgent,
7802
7961
  proxy: sessionProxy ?? undefined,
7803
- }).catch((err) => {
7804
- logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: solve failed (${(0, errors_1.toErrorMessage)(err)}); failing the step rather than silently proceeding`);
7805
- throw err;
7806
- });
7962
+ })
7963
+ .then((solved) => ({ ok: true, solved }))
7964
+ .catch((err) => ({ ok: false, err }));
7965
+ if (!solveResult.ok) {
7966
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
7967
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: solve failed on attempt ${captchaAttempt}/${CAPTCHA_REGISTRY_RETRY_ATTEMPTS} (${(0, errors_1.toErrorMessage)(solveResult.err)}); ${attemptsRemain ? "retrying" : "failing the step rather than silently proceeding"}`);
7968
+ if (attemptsRemain)
7969
+ continue;
7970
+ throw solveResult.err;
7971
+ }
7972
+ const solved = solveResult.solved;
7807
7973
  const preCaptchaCaptureIdx = latestCaptureIndex(recentCaptures);
7808
- const injectResult = await injectCaptchaTokenAndSubmit(captchaTarget, solved.token);
7809
- const registryState = await captchaTarget.evaluate(`(() => {
7974
+ const preCaptchaMetaLength = recentCaptureMeta.length;
7975
+ // Both the inject-and-submit primitive and the registry-state probe
7976
+ // below evaluate against `captchaTarget` after the solve's ~120s poll
7977
+ // has already elapsed, so a page navigation or frame detach that
7978
+ // happened during that wait can leave the frame stale/wedged and
7979
+ // make either evaluate throw via `withWatchdog` — a possibility
7980
+ // distinct from (and unguarded by) the solveCaptcha rejection handled
7981
+ // above. Fold that into the same attempts-remaining tolerance rather
7982
+ // than letting it escape the loop uncaught on every attempt: retry
7983
+ // when attempts remain, still throw on the final attempt so a
7984
+ // genuine failure fails the step per the comment below.
7985
+ let injectResult;
7986
+ let registryState;
7987
+ try {
7988
+ injectResult = await injectCaptchaTokenAndSubmit(captchaTarget, solved.token);
7989
+ registryState = await captchaTarget.evaluate(`(() => {
7810
7990
  const sitekey = ${JSON.stringify(siteKey)};
7811
7991
  const findSitekeyEl = function () {
7812
7992
  return Array.prototype.find.call(
@@ -7832,6 +8012,14 @@ async function executeStepWithHealing(params) {
7832
8012
  if (hcaptchaLoaded && widgetRendered) return "renderedUnmatched";
7833
8013
  return "empty";
7834
8014
  })()`);
8015
+ }
8016
+ catch (err) {
8017
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
8018
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: token inject/registry-probe threw on attempt ${captchaAttempt}/${CAPTCHA_REGISTRY_RETRY_ATTEMPTS} (${(0, errors_1.toErrorMessage)(err)}); ${attemptsRemain ? "retrying" : "failing the step rather than silently proceeding"}`);
8019
+ if (attemptsRemain)
8020
+ continue;
8021
+ throw err;
8022
+ }
7835
8023
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: attempt=${captchaAttempt}/${CAPTCHA_REGISTRY_RETRY_ATTEMPTS} token injected=${injectResult.injected} hasForm=${injectResult.hasForm} callbackDiscovered=${injectResult.callbackDiscovered} registryState=${registryState}`);
7836
8024
  // Reuse the EXISTING waitForTransitionBody poll (no new captcha-specific
7837
8025
  // poll loop), just with a widened budget so the solve+submit round trip
@@ -7856,9 +8044,32 @@ async function executeStepWithHealing(params) {
7856
8044
  // only issue an explicit submit when no transition was observed AND a
7857
8045
  // form is known to exist to submit — otherwise this would double-submit
7858
8046
  // a form the widget's own callback already advanced.
8047
+ let fallbackSubmitted = false;
7859
8048
  if (!confirmed && injectResult.hasForm) {
7860
- await submitCaptchaGatedForm(captchaTarget);
7861
- if (advanceTransitionBodyPattern) {
8049
+ try {
8050
+ fallbackSubmitted = await submitCaptchaGatedForm(captchaTarget, "h-captcha-response", {
8051
+ signalCounter,
8052
+ page,
8053
+ });
8054
+ }
8055
+ catch (err) {
8056
+ // A genuine in-page exception from the click/submit dispatch
8057
+ // (classified via `isNavigatingEvaluateRejection` inside
8058
+ // `submitCaptchaGatedForm`) — not the expected context-teardown
8059
+ // rejection of a submit that navigated the frame. Fold into the
8060
+ // same attempts-remaining tolerance as the inject/registry-probe
8061
+ // catch above rather than letting it silently resolve as a
8062
+ // no-op fallback.
8063
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
8064
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: explicit submit fallback threw a genuine in-page exception on attempt ${captchaAttempt}/${CAPTCHA_REGISTRY_RETRY_ATTEMPTS} (${(0, errors_1.toErrorMessage)(err)}); ${attemptsRemain ? "retrying" : "failing the step rather than silently proceeding"}`);
8065
+ if (attemptsRemain)
8066
+ continue;
8067
+ throw err;
8068
+ }
8069
+ if (!fallbackSubmitted) {
8070
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: explicit submit fallback found no field/form to act on (widget likely tore down or replaced it after the callback fired); nothing was submitted`);
8071
+ }
8072
+ else if (advanceTransitionBodyPattern) {
7862
8073
  confirmed = await waitForTransitionBody({
7863
8074
  page,
7864
8075
  preIdx: preCaptchaCaptureIdx,
@@ -7891,13 +8102,53 @@ async function executeStepWithHealing(params) {
7891
8102
  trajectory?.push({ stepIndex, verifiedBy: "url" });
7892
8103
  return "completed";
7893
8104
  }
8105
+ // Neither transition poll above can see a same-origin submit whose
8106
+ // response never changes the page's URL (an XHR/fetch-driven submit
8107
+ // with no client-side redirect). `findRecentPageTransition` is the
8108
+ // SAME network-capture detector the probe-absent path below already
8109
+ // relies on for exactly this case — reusing it here, scoped to the
8110
+ // captures landed since this attempt's solve, catches the submit a
8111
+ // clean callback dispatches even when the URL/origin never moves.
8112
+ const networkTransitionUrl = findRecentPageTransition({
8113
+ recentCaptureMeta,
8114
+ preMetaLength: preCaptchaMetaLength,
8115
+ });
8116
+ if (networkTransitionUrl !== null) {
8117
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-submit network response confirmed the advance (${networkTransitionUrl})`);
8118
+ trajectory?.push({ stepIndex, verifiedBy: "network" });
8119
+ return "completed";
8120
+ }
8121
+ // The discovered callback threw a genuine in-page exception during
8122
+ // its invoke (classified via `isNavigatingEvaluateRejection`), and
8123
+ // none of the transition detectors above confirmed an advance
8124
+ // either. Checked BEFORE `shouldRetryCaptchaRegistry` below so the
8125
+ // swallowed exception is always logged and threaded into the retry
8126
+ // decision — never silently absorbed into that check's generic
8127
+ // "callback fired but no nav" retry reasoning, which says nothing
8128
+ // about a real thrown error having occurred. A thrown exception is
8129
+ // direct evidence the token was never actually delivered, not merely
8130
+ // an absence of a poll result, so it must never be folded into the
8131
+ // "clean callback, no evidence either way" fallthrough further below
8132
+ // either. Retry while attempts remain (the exception may be
8133
+ // transient, e.g. a stale frame on this attempt — re-resolved at the
8134
+ // top of the next iteration), else fail loudly naming the real cause
8135
+ // instead of silently reporting `callbackDiscovered=true`/
8136
+ // `injected=true` and falling through to the phantom-click cascade
8137
+ // with no trace of it.
8138
+ if (injectResult.callbackInvokeError) {
8139
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
8140
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: the discovered callback threw a genuine in-page exception on attempt ${captchaAttempt}/${CAPTCHA_REGISTRY_RETRY_ATTEMPTS} (${injectResult.callbackInvokeError}); ${attemptsRemain ? "retrying" : "failing the step rather than silently falling through to the cascade"}`);
8141
+ if (attemptsRemain)
8142
+ continue;
8143
+ throw new errors_2.CaptchaError(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: the discovered callback threw a genuine in-page exception (${injectResult.callbackInvokeError}) instead of delivering the token, and no transition was confirmed after the solve`);
8144
+ }
7894
8145
  // The intermittent race this loop exists for: a still-empty/absent
7895
8146
  // registry means the callback attach hadn't landed when this attempt
7896
8147
  // solved+injected, and a discovered-but-unconfirmed callback means it
7897
8148
  // fired without a poll seeing the resulting transition. Both are
7898
8149
  // worth another attempt within budget rather than an immediate throw.
7899
8150
  if (shouldRetryCaptchaRegistry(captchaAttempt, CAPTCHA_REGISTRY_RETRY_ATTEMPTS, registryState, injectResult.callbackDiscovered, confirmed)) {
7900
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: registryState=${registryState} callbackDiscovered=${injectResult.callbackDiscovered} with no confirmed transition on attempt ${captchaAttempt}; retrying`);
8151
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: registryState=${registryState} callbackDiscovered=${injectResult.callbackDiscovered} fallbackSubmitted=${fallbackSubmitted} with no confirmed transition on attempt ${captchaAttempt}; retrying`);
7901
8152
  continue;
7902
8153
  }
7903
8154
  // No render-config callback was discoverable AND neither the inject's
@@ -7911,9 +8162,25 @@ async function executeStepWithHealing(params) {
7911
8162
  // none configured, no network poll ever ran, so there's nothing here
7912
8163
  // to contradict the normal cascade/verifier.
7913
8164
  if (advanceTransitionBodyPattern && !injectResult.callbackDiscovered) {
7914
- throw new errors_2.CaptchaError(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: no render-config callback could be found and delivered, and no transition was confirmed after the solve`);
8165
+ const fallbackDetail = injectResult.hasForm && !fallbackSubmitted
8166
+ ? " and the explicit submit fallback found no field/form to act on"
8167
+ : "";
8168
+ throw new errors_2.CaptchaError(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: no render-config callback could be found and delivered, and no transition was confirmed after the solve${fallbackDetail}`);
7915
8169
  }
7916
- break;
8170
+ // The remaining shape: a render-config callback WAS cleanly
8171
+ // discovered and invoked on every attempt (or no pattern was
8172
+ // configured to distinguish that from the branch above), yet
8173
+ // neither the navigation poll nor the network-capture scan ever
8174
+ // confirmed an advance on any attempt. `shouldRetryCaptchaRegistry`
8175
+ // above already exhausted every attempt worth retrying, so this is
8176
+ // not a registry race — the token was solved and handed to the
8177
+ // widget's own callback, but nothing observable ever moved. Fail
8178
+ // loudly here too instead of `break`-ing into the normal
8179
+ // phantom-click cascade as if the solve had worked; that fallthrough
8180
+ // is the exact misleading "callbackDiscovered=true" silent no-op
8181
+ // this hook must never produce, pattern-configured or not.
8182
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: registryState=${registryState} callbackDiscovered=${injectResult.callbackDiscovered} produced no confirmed transition after exhausting all ${CAPTCHA_REGISTRY_RETRY_ATTEMPTS} attempts; failing the step rather than silently falling through to the cascade`);
8183
+ throw new errors_2.CaptchaError(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: registryState=${registryState} callbackDiscovered=${injectResult.callbackDiscovered} produced no confirmed transition after exhausting all attempts`);
7917
8184
  }
7918
8185
  }
7919
8186
  }
@@ -9210,9 +9477,7 @@ async function executeStepWithHealing(params) {
9210
9477
  // ng-invalid marker count grew (see isClickViewSwapVerified's doc comment).
9211
9478
  const clickViewSwapVerified = isClickViewSwapVerified({
9212
9479
  resolvedAction,
9213
- isFinalStep,
9214
9480
  submitStep,
9215
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9216
9481
  isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
9217
9482
  networkDelta: post.networkCount - pre.networkCount,
9218
9483
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
@@ -9330,10 +9595,49 @@ async function executeStepWithHealing(params) {
9330
9595
  }
9331
9596
  }
9332
9597
  }
9598
+ // Phantom-click verdict, computed from the same pre/post pair the
9599
+ // signals above were derived from. Read HERE — at the point the retry
9600
+ // loop decides whether to keep going — rather than only after the
9601
+ // `verified` gate has already failed the attempt: an `"effective"`
9602
+ // verdict (a real, observable change) must end the attempt loop
9603
+ // immediately regardless of step shape (final, submit, or ordinary
9604
+ // interior toggle), the same as the narrower `verified` boolean does.
9605
+ // Previously the verdict was computed only for post-failure diagnostics/
9606
+ // escalation, so an attempt explicitly classified `"effective"` could
9607
+ // still be followed by wasted further attempts (see
9608
+ // recon-browser-1.12.54-phantom-click-verdict-inconsistent-blocks-terminal-tab-toggle-steps-from-ever-completing.md).
9609
+ record.phantomClickVerdict = (0, phantom_click_1.classifyPhantomClick)({
9610
+ actResultSuccess: record.actResultSuccess,
9611
+ pre,
9612
+ post,
9613
+ elementStateChanged: domVerified,
9614
+ isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
9615
+ });
9616
+ // An `"effective"` verdict driven purely by the page-wide byte-delta
9617
+ // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
9618
+ // here: `clickViewSwapVerified`/`formValueVerified` already own that
9619
+ // signal with the correct, much higher view-swap thresholds (5000B, or
9620
+ // 500B+textChanged for a reveal) — see
9621
+ // flow-runner.client-side-view-swap-cascade.test.ts's <5KB fixture,
9622
+ // which must stay unverified so the cascade keeps excluding candidates.
9623
+ // The one verdict-driven signal genuinely missing from `verified` is the
9624
+ // element-scoped selection-state change (`domVerified`): a same-page
9625
+ // toggle whose own committed state flips (Base Web `kind`/class, ARIA,
9626
+ // native `checked`) with no network/URL/advance-pattern match had NO
9627
+ // credit path at all outside the n+16 fallback's own checkbox-specific
9628
+ // check, even though `classifyPhantomClick` already classifies it
9629
+ // `"effective"` (excluded only on submit-shaped steps, to keep a stray
9630
+ // self-toggle from defeating the submit escalation). Gated through
9631
+ // `domVerifiedForStep` (not raw `domVerified`) so this credit path
9632
+ // respects the SAME DOM-only-advance veto as the block above: a wizard
9633
+ // "Next" step on a pattern-configured site whose only signal is a field
9634
+ // toggle must stay unverified, not get waved through by the verdict.
9635
+ const domEffectiveVerdict = !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)) && domVerifiedForStep;
9333
9636
  let verified = networkIsRealAdvance ||
9334
9637
  urlChanged ||
9335
9638
  domVerifiedForStep ||
9336
9639
  datepickerCommitted ||
9640
+ domEffectiveVerdict ||
9337
9641
  (!datepickerRejected &&
9338
9642
  !promptSelectorRejected &&
9339
9643
  (clickViewSwapVerified || formValueVerified));
@@ -9351,7 +9655,20 @@ async function executeStepWithHealing(params) {
9351
9655
  // state. A 2xx network response alone is insufficient (could be
9352
9656
  // telemetry). The Haiku judge defaults to verified=false when
9353
9657
  // ambiguous — strong evidence, not lax permission.
9354
- if (verified && requireSubmitEndpoint) {
9658
+ //
9659
+ // Only re-litigate an already-`verified` step through the judge when
9660
+ // the step is EXPLICITLY the submit (`submitStep: true`) or a genuine
9661
+ // transition fired (`networkIsRealAdvance` / `urlChanged`). A step that
9662
+ // reached `verified` purely via the element-scoped selection-state
9663
+ // credit (`domVerifiedForStep`) with no explicit `submitStep` flag is
9664
+ // the inferred-final-step case — a same-page toggle the recon slice
9665
+ // happened to end on, not the flow's real submit action (see
9666
+ // flow-runner.final-step-toggle-not-submit-shaped-acceptance.test.ts).
9667
+ // Forcing that toggle through the submit judge (which requires a DOM/
9668
+ // URL/title post-submit signal it will never produce) false-negatives
9669
+ // an already-genuine credit.
9670
+ const hasSubmitTransitionSignal = submitStep || networkIsRealAdvance || urlChanged;
9671
+ if (verified && requireSubmitEndpoint && hasSubmitTransitionSignal) {
9355
9672
  // Cap the scan from preMetaLength so we don't accept a historical
9356
9673
  // submit-shaped capture from an earlier step as proof for this one.
9357
9674
  const tail = recentCaptureMeta.slice(preMetaLength);
@@ -9698,6 +10015,36 @@ async function executeStepWithHealing(params) {
9698
10015
  const weakDomSignalsAllowed = ((!isFinalStep && !submitStep) || requireSubmitEndpoint) &&
9699
10016
  !isCheckboxOrRadioIntentStep(step) &&
9700
10017
  !clickTargetIsSelectionMarker;
10018
+ // Same effective-verdict credit the primary attempt's completion
10019
+ // gate uses (see the `record.phantomClickVerdict` computation
10020
+ // above), re-run against THIS fallback click's own pre/post pair
10021
+ // (`pre`/`retryPost`) rather than the stale pre/post the primary
10022
+ // technique captured — the primary technique's own act() call may
10023
+ // not have produced any DOM effect at all, with the fallback's
10024
+ // `el.click()` being the click that actually landed. Still subject
10025
+ // to the `clickBlockedByDisabled`/`clickBlockedByInvalid`/
10026
+ // `fallbackDomOnlyAdvance` vetoes below, same as every other
10027
+ // fallback signal. `classifyPhantomClick` can still classify a
10028
+ // submit-shaped step "effective" purely off the page-wide
10029
+ // `TRIVIAL_DOM_DELTA_BYTES` floor (elementStateChanged is already
10030
+ // excluded on submit-shaped steps below via `isSubmitShapedStep`),
10031
+ // so the byte-delta-only branch of that verdict must not bypass
10032
+ // the submit-endpoint corroboration gate any more than the raw
10033
+ // `retryHtmlDelta`/`retryTextChanged` signals below may — same
10034
+ // `retrySubmitShaped` exclusion the primary gate's
10035
+ // `domEffectiveVerdict` applies. See
10036
+ // flow-runner.viewswap-blocked-submit-acceptance.test.ts (must stay
10037
+ // gated) and flow-runner.pricing-tab-symmetric-swap-verdict-
10038
+ // acceptance.test.ts (a non-submit-shaped final step must still get
10039
+ // credit here).
10040
+ const retrySubmitShaped = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag);
10041
+ const retryVerdict = (0, phantom_click_1.classifyPhantomClick)({
10042
+ actResultSuccess: record.actResultSuccess,
10043
+ pre,
10044
+ post: retryPost,
10045
+ elementStateChanged: retrySelectionStateChanged,
10046
+ isSubmitShapedStep: retrySubmitShaped,
10047
+ });
9701
10048
  let retryVerified = !clickBlockedByDisabled &&
9702
10049
  !clickBlockedByInvalid &&
9703
10050
  !fallbackDomOnlyAdvance &&
@@ -9705,14 +10052,25 @@ async function executeStepWithHealing(params) {
9705
10052
  retryUrlChanged ||
9706
10053
  checkboxStateVerified ||
9707
10054
  retrySelectionStateChanged ||
10055
+ (!retrySubmitShaped && retryVerdict === "effective") ||
9708
10056
  (weakDomSignalsAllowed &&
9709
10057
  (retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
10058
+ if (retryVerified) {
10059
+ record.phantomClickVerdict = retryVerdict;
10060
+ }
9710
10061
  // Apply the same submit-endpoint gate the primary verifier uses.
9711
10062
  // Without this, the n+16 fallback would still ride past a
9712
10063
  // tracking-pixel-only click on the final step. Same Haiku LLM
9713
10064
  // judgment as the primary verifier — multi-signal corroboration
9714
- // replaces deterministic URL regex matching.
9715
- if (retryVerified && requireSubmitEndpoint) {
10065
+ // replaces deterministic URL regex matching. Same
10066
+ // hasSubmitTransitionSignal carve-out as the primary verifier: an
10067
+ // inferred (non-explicit-submitStep) final step whose n+16 credit
10068
+ // came only from the element-scoped `retrySelectionStateChanged`
10069
+ // signal is the same ordinary-toggle shape, not a real submit —
10070
+ // don't force it through a judge that requires evidence it can
10071
+ // never produce.
10072
+ const retryHasSubmitTransitionSignal = submitStep || retryNetworkIsRealAdvance || retryUrlChanged;
10073
+ if (retryVerified && requireSubmitEndpoint && retryHasSubmitTransitionSignal) {
9716
10074
  const tail = recentCaptureMeta.slice(preMetaLength);
9717
10075
  // DOM-state probe (deterministic).
9718
10076
  let domSubmittedMatch = null;
@@ -9820,22 +10178,10 @@ async function executeStepWithHealing(params) {
9820
10178
  return "completed";
9821
10179
  }
9822
10180
  const effectSignals = describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLength);
9823
- // Phantom-click verdict, computed from the SAME pre/post pair
9824
- // describeAttemptEffectSignals just rendered — not recomputed deltas.
9825
- // Recorded on every unverified attempt (not just attempt 1) so the
9826
- // failure dump's attempts[] always carries the classification; only
9827
- // attempt 1's verdict drives the escalation flag below.
9828
- record.phantomClickVerdict = (0, phantom_click_1.classifyPhantomClick)({
9829
- actResultSuccess: record.actResultSuccess,
9830
- pre,
9831
- post,
9832
- // Authoritative element-scoped signal: `verifyDomEffect` read the resolved
9833
- // element's own committed-state delta (Base Web `kind`/class, ARIA, native
9834
- // checked) into `domVerified`. A registered selection toggle no longer
9835
- // reads as a phantom just because it moved no network/URL/bytes.
9836
- elementStateChanged: domVerified,
9837
- isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
9838
- });
10181
+ // record.phantomClickVerdict was already computed above, before the
10182
+ // `verified` gate — reaching this point means it is NOT `"effective"`
10183
+ // (an effective verdict would have short-circuited to "completed"
10184
+ // above), so it is either `"phantom"` or `"unresolved"` here.
9839
10185
  const reason = record.errorMessage
9840
10186
  ? effectSignals
9841
10187
  ? `${record.errorMessage}; ${effectSignals}`