@enricai/barnacle 1.12.77 → 1.12.79

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 (63) hide show
  1. package/dist/plugins/config-plugin.d.ts.map +1 -1
  2. package/dist/plugins/config-plugin.js +2 -1
  3. package/dist/plugins/config-plugin.js.map +1 -1
  4. package/dist/scraper/actionable-visibility-expr.d.ts +12 -0
  5. package/dist/scraper/actionable-visibility-expr.d.ts.map +1 -0
  6. package/dist/scraper/actionable-visibility-expr.js +22 -0
  7. package/dist/scraper/actionable-visibility-expr.js.map +1 -0
  8. package/dist/scraper/browser-click-expr.d.ts +1 -1
  9. package/dist/scraper/browser-click-expr.d.ts.map +1 -1
  10. package/dist/scraper/browser-click-expr.js +2 -6
  11. package/dist/scraper/browser-click-expr.js.map +1 -1
  12. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
  13. package/dist/scraper/deep-locator-candidates.js +35 -11
  14. package/dist/scraper/deep-locator-candidates.js.map +1 -1
  15. package/dist/scraper/deep-locator-scan.d.ts +12 -0
  16. package/dist/scraper/deep-locator-scan.d.ts.map +1 -1
  17. package/dist/scraper/deep-locator-scan.js +42 -13
  18. package/dist/scraper/deep-locator-scan.js.map +1 -1
  19. package/dist/scraper/errors.d.ts +10 -0
  20. package/dist/scraper/errors.d.ts.map +1 -1
  21. package/dist/scraper/errors.js +19 -1
  22. package/dist/scraper/errors.js.map +1 -1
  23. package/dist/scraper/flow-runner.d.ts +47 -0
  24. package/dist/scraper/flow-runner.d.ts.map +1 -1
  25. package/dist/scraper/flow-runner.js +310 -61
  26. package/dist/scraper/flow-runner.js.map +1 -1
  27. package/dist/scraper/phantom-click.d.ts +3 -3
  28. package/dist/scraper/phantom-click.d.ts.map +1 -1
  29. package/dist/scraper/phantom-click.js +6 -14
  30. package/dist/scraper/phantom-click.js.map +1 -1
  31. package/dist/scraper/pool.d.ts.map +1 -1
  32. package/dist/scraper/pool.js +8 -1
  33. package/dist/scraper/pool.js.map +1 -1
  34. package/dist/scraper/retry.d.ts +1 -1
  35. package/dist/scraper/retry.d.ts.map +1 -1
  36. package/dist/scraper/retry.js +5 -1
  37. package/dist/scraper/retry.js.map +1 -1
  38. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  39. package/dist/scraper/session-browserbase.js +29 -17
  40. package/dist/scraper/session-browserbase.js.map +1 -1
  41. package/dist/scraper/session-shared.d.ts +5 -0
  42. package/dist/scraper/session-shared.d.ts.map +1 -1
  43. package/dist/scraper/session-shared.js.map +1 -1
  44. package/dist/scraper/session-teardown.d.ts +30 -1
  45. package/dist/scraper/session-teardown.d.ts.map +1 -1
  46. package/dist/scraper/session-teardown.js +48 -3
  47. package/dist/scraper/session-teardown.js.map +1 -1
  48. package/dist/scraper/submit-control.d.ts +2 -2
  49. package/dist/scraper/submit-control.d.ts.map +1 -1
  50. package/dist/scraper/submit-control.js +5 -21
  51. package/dist/scraper/submit-control.js.map +1 -1
  52. package/dist/scraper/target-resolution-diagnostic.d.ts.map +1 -1
  53. package/dist/scraper/target-resolution-diagnostic.js +6 -11
  54. package/dist/scraper/target-resolution-diagnostic.js.map +1 -1
  55. package/dist/scripts/recon-browser.d.ts +4 -1
  56. package/dist/scripts/recon-browser.d.ts.map +1 -1
  57. package/dist/scripts/recon-browser.js +25 -6
  58. package/dist/scripts/recon-browser.js.map +1 -1
  59. package/dist/scripts/recon-generate.d.ts +117 -2
  60. package/dist/scripts/recon-generate.d.ts.map +1 -1
  61. package/dist/scripts/recon-generate.js +801 -166
  62. package/dist/scripts/recon-generate.js.map +1 -1
  63. package/package.json +1 -1
@@ -27,6 +27,9 @@ exports.findRecentPageTransition = findRecentPageTransition;
27
27
  exports.isWizardExitAction = isWizardExitAction;
28
28
  exports.isAdvanceStep = isAdvanceStep;
29
29
  exports.isSubmitIntentStep = isSubmitIntentStep;
30
+ exports.stripNegatedSubmitClauses = stripNegatedSubmitClauses;
31
+ exports.classifyStepSubmitIntent = classifyStepSubmitIntent;
32
+ exports.isStepSubmitShaped = isStepSubmitShaped;
30
33
  exports.isCheckboxOrRadioIntentStep = isCheckboxOrRadioIntentStep;
31
34
  exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
32
35
  exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
@@ -1141,6 +1144,14 @@ function findRecentBackendError(params) {
1141
1144
  }
1142
1145
  return null;
1143
1146
  }
1147
+ /**
1148
+ * Network-capture transitions say a request succeeded, not where the page
1149
+ * landed, so a credit must also see a landed URL the step could plausibly have
1150
+ * targeted (e.g. not a sign-in bounce the instruction never asked for).
1151
+ */
1152
+ async function isLandedUrlPlausibleForStep(page, target, step) {
1153
+ return (0, phantom_click_1.isPlausibleStepDestination)(step, await (0, frame_target_1.readCurrentFrameUrl)(page, target));
1154
+ }
1144
1155
  /**
1145
1156
  * Detect whether the page legitimately transitioned within the supplied
1146
1157
  * capture-meta window — a 3xx redirect or a same-origin non-GET capture
@@ -1277,6 +1288,60 @@ function isSubmitIntentStep(instruction) {
1277
1288
  const haystack = instruction.toLowerCase();
1278
1289
  return SUBMIT_INTENT_STEP_PHRASES.some((p) => haystack.includes(p));
1279
1290
  }
1291
+ /**
1292
+ * Targets whose negation declares the step is not a submit: a checkout/payment/
1293
+ * order/submit action, or a "proceeds/leads/advances to" phrase naming one.
1294
+ * Bare "do not click Book Now" stays undeclared — the control name alone says
1295
+ * nothing about whether the author means a submit.
1296
+ */
1297
+ const SUBMIT_TARGET_SRC = "(?:(?:proceed|advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:\\w+\\s+)?(?:checkout|check ?out|payments?|purchase|order|booking|reservation)|(?:place|complete|finali[sz]e|confirm)(?:s|ing)?\\s+(?:the\\s+|your\\s+|an?\\s+)?(?:order|purchase|booking|reservation|payment|checkout)|(?:advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with|past|through)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:next|following|final|last)\\s+(?:step|page|screen|stage)|(?:advance|advances|advancing)\\s+(?:the\\s+|your\\s+)?(?:booking|order|reservation|purchase)|check ?out|pay(?:ment|ing)?|purchas(?:e|es|ing)|buy(?:s|ing)?|charg(?:e|es|ing)|submit(?:s|ting)?)";
1298
+ /**
1299
+ * A negation followed, in the same clause, by a submit target ("Do NOT click
1300
+ * ... any control that proceeds to checkout"), or an explicit "this is not a
1301
+ * submit click" / "without submitting" declaration.
1302
+ */
1303
+ const NEGATED_SUBMIT_CLAUSE = new RegExp(`\\b(?:do not|don't|dont|never|must not|should not|shouldn't|does not|doesn't|avoid|refrain from)\\b[^.;\\n]*?\\b${SUBMIT_TARGET_SRC}\\b[^.;\\n]*|\\b(?:(?:this|that|it)\\s+(?:is|are)\\s+not|is not|isn't|not)\\s+(?:a|an|the)?\\s*(?:final\\s+|actual\\s+)?(?:submit|submission|checkout|payment|purchase)\\b[^.;\\n]*|\\bwithout\\s+(?:${SUBMIT_TARGET_SRC})\\b[^.;\\n]*`, "gi");
1304
+ /** A positive submit imperative outside any negated clause ("Submit the form."). */
1305
+ const LEADING_SUBMIT_IMPERATIVE = /(?:^|[.;]\s*)(?:then\s+)?submit\b/i;
1306
+ /**
1307
+ * The instruction with every negated submit clause blanked, so callers that
1308
+ * match submit wording on the step text (flow-load seeding) read only what the
1309
+ * step positively asks for and never a guardrail ("Do NOT ... proceeds to
1310
+ * checkout"). Pure.
1311
+ */
1312
+ function stripNegatedSubmitClauses(instruction) {
1313
+ return instruction.replace(NEGATED_SUBMIT_CLAUSE, " ");
1314
+ }
1315
+ /**
1316
+ * Reads the step's own instruction for a declared submit intent, so an
1317
+ * explicit "this is NOT a submit/checkout click" can veto inferred submit
1318
+ * shape. A positive submit phrase outside the negated clause keeps "submit"
1319
+ * so a real submit step that mentions a guardrail is not demoted. Pure.
1320
+ */
1321
+ function classifyStepSubmitIntent(instruction) {
1322
+ if (!instruction)
1323
+ return "unspecified";
1324
+ const remainder = stripNegatedSubmitClauses(instruction);
1325
+ if (isSubmitIntentStep(remainder) || LEADING_SUBMIT_IMPERATIVE.test(remainder))
1326
+ return "submit";
1327
+ if (remainder !== instruction)
1328
+ return "non-submit";
1329
+ return isSubmitIntentStep(instruction) ? "submit" : "unspecified";
1330
+ }
1331
+ /**
1332
+ * Single seam answering "is this step submit-shaped?": an explicit `submitStep`
1333
+ * flag is authoritative, a declared non-submit instruction vetoes every
1334
+ * inferred path, otherwise final-step inference or submit-intent wording applies.
1335
+ */
1336
+ function isStepSubmitShaped(params) {
1337
+ const { step, submitStep, isFinalStep, flowHasSubmitSemantics: flowSubmit } = params;
1338
+ if (submitStep)
1339
+ return true;
1340
+ const intent = classifyStepSubmitIntent(step);
1341
+ if (intent === "non-submit")
1342
+ return false;
1343
+ return (isFinalStep && flowSubmit) || intent === "submit";
1344
+ }
1280
1345
  /**
1281
1346
  * Is this flow step's intent to check a checkbox or select a radio option?
1282
1347
  * Used to veto a weak page-wide DOM signal (htmlDelta/textChanged/formValueChanged)
@@ -1623,13 +1688,26 @@ function isDomOnlyAdvanceVerified(params) {
1623
1688
  * when the probe can't run (non-xpath selector, evaluate failure) it stays
1624
1689
  * at its default `true` so it never manufactures a veto out of missing
1625
1690
  * information. Only a confirmed `false` blocks the credit.
1691
+ *
1692
+ * **Destination-plausibility veto:** none of the signals above look at
1693
+ * WHERE the click landed. A non-submit click that bounces to a page
1694
+ * {@link isPlausibleStepDestination} rejects as implausible for the step
1695
+ * (e.g. a settings-panel tab switcher landing on an unrelated marketing
1696
+ * page) can still clear every byte/text threshold above, crediting a
1697
+ * phantom transition the same way a same-page toggle is credited.
1698
+ * `destinationPlausible` is optional and defaults to the `!== false`
1699
+ * no-veto behavior — matching the sibling `destinationPlausible` param on
1700
+ * `classifyPhantomClick` — so existing/future callers that don't thread it
1701
+ * are unaffected.
1626
1702
  */
1627
1703
  function isClickViewSwapVerified(params) {
1628
1704
  const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
1629
1705
  const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
1630
- const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, clickedElementStillPresent = true, resolvedElementIsSubmitShaped = false, } = params;
1706
+ const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, clickedElementStillPresent = true, resolvedElementIsSubmitShaped = false, destinationPlausible, } = params;
1631
1707
  if (resolvedAction?.method !== "click")
1632
1708
  return false;
1709
+ if (destinationPlausible === false)
1710
+ return false;
1633
1711
  // Only the step's own explicit submitStep flag identifies the step that
1634
1712
  // actually needs network/URL verification — mirrors the submit-judge
1635
1713
  // gate's hasSubmitTransitionSignal discipline (see 5763ac2). Inferring
@@ -3341,7 +3419,7 @@ function xpathTailForRetarget(xpath) {
3341
3419
  * bar, preserving the previous behavior for every single-match (the common)
3342
3420
  * case.
3343
3421
  */
3344
- const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail) => {
3422
+ const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail, preferSubmit) => {
3345
3423
  const isNegative = ${submit_control_1.NEGATIVE_TEXT_EXPR};
3346
3424
  const accessibleName = (el) =>
3347
3425
  (el.getAttribute("aria-label") || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
@@ -3350,6 +3428,7 @@ const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail) => {
3350
3428
  const nodes = [];
3351
3429
  for (let i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
3352
3430
  if (nodes.length <= 1) return nodes[0] || null;
3431
+ if (!preferSubmit) return nodes[0];
3353
3432
  const preferred = nodes.find((el) => isSubmitShaped(el) && !isNegative(accessibleName(el)));
3354
3433
  return preferred || nodes[0];
3355
3434
  })`;
@@ -3696,7 +3775,7 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
3696
3775
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3697
3776
  let el = r.singleNodeValue;
3698
3777
  if (!el && ${JSON.stringify(xpathTail)}) {
3699
- el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
3778
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)});
3700
3779
  }
3701
3780
  const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
3702
3781
  return isSubmitShaped(el);
@@ -4873,10 +4952,11 @@ Object.defineProperty(exports, "hasOriginOrPathChanged", { enumerable: true, get
4873
4952
  * independently of whether `advanceTransitionBodyPattern` is configured.
4874
4953
  */
4875
4954
  async function waitForCaptchaNavigation(params) {
4876
- const { page, captchaTarget, baselineUrl, timeoutMs, intervalMs } = params;
4955
+ const { page, captchaTarget, baselineUrl, step, timeoutMs, intervalMs } = params;
4877
4956
  const check = async () => {
4878
4957
  const currentUrl = await (0, frame_target_1.readCurrentFrameUrl)(page, captchaTarget);
4879
- return (0, phantom_click_1.hasOriginOrPathChanged)(baselineUrl, currentUrl);
4958
+ return ((0, phantom_click_1.hasOriginOrPathChanged)(baselineUrl, currentUrl) &&
4959
+ (0, phantom_click_1.isPlausibleStepDestination)(step, currentUrl));
4880
4960
  };
4881
4961
  if (await check())
4882
4962
  return true;
@@ -5246,6 +5326,7 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
5246
5326
  pre,
5247
5327
  post,
5248
5328
  isSubmitShapedStep: true,
5329
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url),
5249
5330
  });
5250
5331
  if (verdict !== "phantom")
5251
5332
  return true;
@@ -5258,8 +5339,18 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
5258
5339
  return { clicked: false };
5259
5340
  throw err;
5260
5341
  });
5261
- if (runnerUpClickResult.clicked)
5262
- return true;
5342
+ if (runnerUpClickResult.clicked) {
5343
+ const runnerUpPost = await snapshotPage(target, verification.signalCounter, verification.page);
5344
+ const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
5345
+ actResultSuccess: true,
5346
+ pre: post,
5347
+ post: runnerUpPost,
5348
+ isSubmitShapedStep: true,
5349
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url),
5350
+ });
5351
+ if (runnerUpVerdict !== "phantom")
5352
+ return true;
5353
+ }
5263
5354
  }
5264
5355
  }
5265
5356
  }
@@ -7973,7 +8064,7 @@ function redactIfSensitive(text, sensitiveValue) {
7973
8064
  * candidate was actually clickable, or every click phantomed.
7974
8065
  */
7975
8066
  async function probeChildFrameSubmitFallback(params) {
7976
- const { page, signalCounter } = params;
8067
+ const { page, signalCounter, step } = params;
7977
8068
  // `page.mainFrameId`/`page.frames` are read defensively: some call sites
7978
8069
  // (and their test fakes) model a `Page` that never attaches child frames at
7979
8070
  // all and doesn't implement this pair, which is indistinguishable here from
@@ -8032,6 +8123,7 @@ async function probeChildFrameSubmitFallback(params) {
8032
8123
  pre,
8033
8124
  post,
8034
8125
  isSubmitShapedStep: true,
8126
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url),
8035
8127
  });
8036
8128
  if (verdict !== "phantom")
8037
8129
  return target;
@@ -8047,6 +8139,7 @@ async function probeChildFrameSubmitFallback(params) {
8047
8139
  pre,
8048
8140
  post: runnerUpPost,
8049
8141
  isSubmitShapedStep: true,
8142
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url),
8050
8143
  });
8051
8144
  if (runnerUpVerdict !== "phantom")
8052
8145
  return target;
@@ -8228,7 +8321,8 @@ async function executeStepWithHealing(params) {
8228
8321
  // Submit, and unfilled required fields produce silent submit failures
8229
8322
  // (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
8230
8323
  // whose submit is mid-list can mark its submit step explicitly.
8231
- const requireSubmitEndpoint = (isFinalStep || submitStep) && submitEndpointPattern !== null;
8324
+ const stepDeclaredNonSubmit = !submitStep && classifyStepSubmitIntent(step) === "non-submit";
8325
+ const requireSubmitEndpoint = (isFinalStep || submitStep) && !stepDeclaredNonSubmit && submitEndpointPattern !== null;
8232
8326
  // Capture the per-element selection baseline (for verifyDomEffect's element-
8233
8327
  // scoped click read-back) ONLY for selection/field-answer click steps — never
8234
8328
  // for submit/advance steps, whose own self-toggling buttons must not
@@ -8380,7 +8474,12 @@ async function executeStepWithHealing(params) {
8380
8474
  // when no solver key is configured or the solve otherwise fails — that
8381
8475
  // propagates out of this function unchanged, so the step fails cleanly
8382
8476
  // instead of silently falling through as if unverified-but-passing.
8383
- const isSubmitOrFinalStep = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step);
8477
+ const isSubmitOrFinalStep = isStepSubmitShaped({
8478
+ step,
8479
+ submitStep,
8480
+ isFinalStep,
8481
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8482
+ });
8384
8483
  if (captchaGated && isSubmitOrFinalStep) {
8385
8484
  let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
8386
8485
  const sitekeyProbeExpr = `(() => {
@@ -8523,6 +8622,7 @@ async function executeStepWithHealing(params) {
8523
8622
  fallbackSubmitted = await submitCaptchaGatedForm(captchaTarget, "h-captcha-response", {
8524
8623
  signalCounter,
8525
8624
  page,
8625
+ step,
8526
8626
  });
8527
8627
  }
8528
8628
  catch (err) {
@@ -8567,6 +8667,7 @@ async function executeStepWithHealing(params) {
8567
8667
  page,
8568
8668
  captchaTarget,
8569
8669
  baselineUrl: pageUrl,
8670
+ step,
8570
8671
  timeoutMs: CAPTCHA_TRANSITION_POLL_MS,
8571
8672
  intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
8572
8673
  });
@@ -8586,7 +8687,8 @@ async function executeStepWithHealing(params) {
8586
8687
  recentCaptureMeta,
8587
8688
  preMetaLength: preCaptchaMetaLength,
8588
8689
  });
8589
- if (networkTransitionUrl !== null) {
8690
+ if (networkTransitionUrl !== null &&
8691
+ (await isLandedUrlPlausibleForStep(page, captchaTarget, step))) {
8590
8692
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-submit network response confirmed the advance (${networkTransitionUrl})`);
8591
8693
  trajectory?.push({ stepIndex, verifiedBy: "network" });
8592
8694
  return "completed";
@@ -8703,7 +8805,8 @@ async function executeStepWithHealing(params) {
8703
8805
  recentCaptureMeta,
8704
8806
  preMetaLength: stepStartMetaLength,
8705
8807
  });
8706
- if (transitionUrl !== null) {
8808
+ if (transitionUrl !== null &&
8809
+ (await isLandedUrlPlausibleForStep(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), step))) {
8707
8810
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (probe absent but recent transition detected: ${transitionUrl})`);
8708
8811
  trajectory?.push({ stepIndex, verifiedBy: "url" });
8709
8812
  return "completed";
@@ -8733,8 +8836,13 @@ async function executeStepWithHealing(params) {
8733
8836
  // a radio/select answer) could complete the step by clicking an unrelated
8734
8837
  // submit control in an unrelated same-origin child iframe.
8735
8838
  if (!frameTarget?.declaredFrameSelector &&
8736
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
8737
- const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter });
8839
+ isStepSubmitShaped({
8840
+ step,
8841
+ submitStep,
8842
+ isFinalStep,
8843
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8844
+ })) {
8845
+ const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter, step });
8738
8846
  if (fallbackTarget) {
8739
8847
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent: no candidate in the resolved frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
8740
8848
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
@@ -8767,7 +8875,12 @@ async function executeStepWithHealing(params) {
8767
8875
  const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
8768
8876
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
8769
8877
  : probeAbsentObservedUnfocused;
8770
- const targetResolutionDiagnostic = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
8878
+ const targetResolutionDiagnostic = isStepSubmitShaped({
8879
+ step,
8880
+ submitStep,
8881
+ isFinalStep,
8882
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8883
+ })
8771
8884
  ? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page)
8772
8885
  : null;
8773
8886
  const dumpPath = onStepFailure?.({
@@ -9052,6 +9165,7 @@ async function executeStepWithHealing(params) {
9052
9165
  // already has an up-to-date ranking to report from.
9053
9166
  let lastRankedSubmitCandidates = null;
9054
9167
  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
9168
+ params.abortSignal?.throwIfAborted();
9055
9169
  // Telemetry-driven technique-skip: when a cascade technique's
9056
9170
  // preconditions cannot be met by the prior attempts' state, running
9057
9171
  // it would burn the attempt slot without exercising new behaviour.
@@ -9060,7 +9174,12 @@ async function executeStepWithHealing(params) {
9060
9174
  if (attempt > 1) {
9061
9175
  const wouldBeTechnique = attempt === 2
9062
9176
  ? attempt1UnreachableViaLightDom &&
9063
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))
9177
+ isStepSubmitShaped({
9178
+ step,
9179
+ submitStep,
9180
+ isFinalStep,
9181
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9182
+ })
9064
9183
  ? "deep-submit-locator"
9065
9184
  : phantomClickAfterAttempt1
9066
9185
  ? "trusted-click-retry"
@@ -9079,7 +9198,12 @@ async function executeStepWithHealing(params) {
9079
9198
  })),
9080
9199
  advanceUnmovedAfterAttempt1,
9081
9200
  attempt1UnreachableViaLightDom,
9082
- submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
9201
+ submitShapedStep: isStepSubmitShaped({
9202
+ step,
9203
+ submitStep,
9204
+ isFinalStep,
9205
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9206
+ }),
9083
9207
  });
9084
9208
  if (decision.skip) {
9085
9209
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
@@ -9217,7 +9341,12 @@ async function executeStepWithHealing(params) {
9217
9341
  }
9218
9342
  else if (attempt === 2 &&
9219
9343
  attempt1UnreachableViaLightDom &&
9220
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
9344
+ isStepSubmitShaped({
9345
+ step,
9346
+ submitStep,
9347
+ isFinalStep,
9348
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9349
+ })) {
9221
9350
  // Deep submit-control locator: attempt 1 either phantom-clicked
9222
9351
  // (Stagehand reported success but pre/post showed zero effect) or
9223
9352
  // came back "unresolved" (act() found zero candidates at all), so the
@@ -9301,6 +9430,7 @@ async function executeStepWithHealing(params) {
9301
9430
  // stray selection flip must not mask a top-pick phantom and skip
9302
9431
  // the runner-up retry.
9303
9432
  isSubmitShapedStep: true,
9433
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url),
9304
9434
  });
9305
9435
  if (topVerdict === "phantom") {
9306
9436
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator top pick (${top.tag} "${top.accessibleName}") phantom-clicked; retrying runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier})`);
@@ -9343,7 +9473,12 @@ async function executeStepWithHealing(params) {
9343
9473
  }
9344
9474
  else if (attempt === 2 &&
9345
9475
  phantomClickAfterAttempt1 &&
9346
- !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
9476
+ !isStepSubmitShaped({
9477
+ step,
9478
+ submitStep,
9479
+ isFinalStep,
9480
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9481
+ })) {
9347
9482
  // Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
9348
9483
  // Stagehand reported success but pre/post showed zero effect. On a
9349
9484
  // design-system widget (React synthetic-event delegation, custom
@@ -9997,7 +10132,10 @@ async function executeStepWithHealing(params) {
9997
10132
  record.resolvedArguments = (resolvedAction.arguments ?? []).map(String);
9998
10133
  }
9999
10134
  const networkFired = post.networkCount > pre.networkCount;
10000
- const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url) && (0, phantom_click_1.isPlausibleStepDestination)(step, post.url);
10135
+ // Computed once: gates every credit signal that takes no post-click URL
10136
+ // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
10137
+ const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url);
10138
+ const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url) && destinationPlausible;
10001
10139
  const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
10002
10140
  const isClick = resolvedAction !== null && resolvedAction.method === "click";
10003
10141
  // State-class actions (fill/check/etc.) never move the network counter or URL,
@@ -10032,7 +10170,7 @@ async function executeStepWithHealing(params) {
10032
10170
  // short-circuits when the transition is already in-window (zero added latency
10033
10171
  // on the common path). Scoped by preCaptureIdx via an eviction-proof disk scan.
10034
10172
  const advanceGateTimeoutMs = captchaGated && isSubmitOrFinalStep ? CAPTCHA_TRANSITION_POLL_MS : ADVANCE_TRANSITION_POLL_MS;
10035
- const networkIsRealAdvance = !advanceGateActive
10173
+ const networkAdvanced = !advanceGateActive
10036
10174
  ? networkFired
10037
10175
  : await waitForTransitionBody({
10038
10176
  page,
@@ -10041,7 +10179,8 @@ async function executeStepWithHealing(params) {
10041
10179
  timeoutMs: advanceGateTimeoutMs,
10042
10180
  intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
10043
10181
  });
10044
- if (advanceGateActive && !networkIsRealAdvance) {
10182
+ const networkIsRealAdvance = networkAdvanced && destinationPlausible;
10183
+ if (advanceGateActive && !networkAdvanced) {
10045
10184
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} network fired but no advance-transition (type=next) body matched within ${advanceGateTimeoutMs}ms poll (non-advancing POST); not treating as verified`);
10046
10185
  }
10047
10186
  // DOM-only-advance veto (opt-in). A rephrase can turn an advance/"Next" step
@@ -10059,15 +10198,20 @@ async function executeStepWithHealing(params) {
10059
10198
  // `advanceTransitionBodyPattern` are unaffected.
10060
10199
  const domVerifiedForStep = isDomOnlyAdvanceVerified({
10061
10200
  hasPattern: advanceTransitionBodyPattern !== null,
10062
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10201
+ isFinalOrSubmit: isStepSubmitShaped({
10202
+ step,
10203
+ submitStep,
10204
+ isFinalStep,
10205
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10206
+ }),
10063
10207
  isAdvance: isAdvanceStep(step),
10064
- domVerified,
10208
+ domVerified: domVerified && destinationPlausible,
10065
10209
  networkIsRealAdvance,
10066
10210
  urlChanged,
10067
10211
  })
10068
- ? domVerified
10212
+ ? domVerified && destinationPlausible
10069
10213
  : false;
10070
- if (domVerified && !domVerifiedForStep) {
10214
+ if (domVerified && !domVerifiedForStep && destinationPlausible) {
10071
10215
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} advance step succeeded only via DOM state change (field toggle / non-advancing POST), not a real transition; not treating as verified`);
10072
10216
  }
10073
10217
  // Blocked-submit veto input for the view-swap gate below. Read post-click
@@ -10092,7 +10236,7 @@ async function executeStepWithHealing(params) {
10092
10236
  // Attribute-based submit-shape read of the resolved click target itself —
10093
10237
  // independent of the flow-authored `submitStep` flag. See
10094
10238
  // resolvedClickTargetIsSubmitShaped's doc comment.
10095
- const resolvedElementIsSubmitShaped = isClick && resolvedAction?.selector
10239
+ const resolvedElementIsSubmitShaped = isClick && !stepDeclaredNonSubmit && resolvedAction?.selector
10096
10240
  ? await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
10097
10241
  : false;
10098
10242
  // Client-side view-swap gate: credit a click that produces substantial
@@ -10102,7 +10246,8 @@ async function executeStepWithHealing(params) {
10102
10246
  // Below that threshold, also credits a smaller text-changing reveal (≥500B) —
10103
10247
  // fixes the top-window site Work-History gate-message reveal (+789B) that used to
10104
10248
  // cascade to a 5-attempt failure and global replan. Vetoed when the click's
10105
- // ng-invalid marker count grew (see isClickViewSwapVerified's doc comment).
10249
+ // ng-invalid marker count grew, or when the landed destination is implausible
10250
+ // for the step (see isClickViewSwapVerified's doc comment).
10106
10251
  const clickViewSwapVerified = isClickViewSwapVerified({
10107
10252
  resolvedAction,
10108
10253
  submitStep,
@@ -10113,6 +10258,7 @@ async function executeStepWithHealing(params) {
10113
10258
  invalidMarkerDelta: postInvalidMarkerCount - preInvalidMarkerCount,
10114
10259
  clickedElementStillPresent,
10115
10260
  resolvedElementIsSubmitShaped,
10261
+ destinationPlausible,
10116
10262
  });
10117
10263
  if (clickViewSwapVerified === false &&
10118
10264
  isClick &&
@@ -10145,12 +10291,15 @@ async function executeStepWithHealing(params) {
10145
10291
  // submitStep, inferred final-step, or an attribute-detected submit
10146
10292
  // control) may not ride this signal alone — only when the submit-judge
10147
10293
  // gate (requireSubmitEndpoint) will re-litigate the credit below.
10148
- const formValueWeakSignalAllowed = !(submitStep ||
10149
- (isFinalStep && flowHasSubmitSemanticsFlag) ||
10150
- resolvedElementIsSubmitShaped ||
10151
- isSubmitIntentStep(step)) || requireSubmitEndpoint;
10294
+ const formValueWeakSignalAllowed = !(isStepSubmitShaped({
10295
+ step,
10296
+ submitStep,
10297
+ isFinalStep,
10298
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10299
+ }) || resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
10152
10300
  const formValueVerified = isStateClass &&
10153
10301
  formValueWeakSignalAllowed &&
10302
+ destinationPlausible &&
10154
10303
  post.formValueSignature !== pre.formValueSignature;
10155
10304
  // Committed-value guard on the act-success path. A controlled datepicker
10156
10305
  // (react-datepicker) accepts the typed value, discards it on React's next
@@ -10253,9 +10402,14 @@ async function executeStepWithHealing(params) {
10253
10402
  actResultSuccess: record.actResultSuccess,
10254
10403
  pre,
10255
10404
  post,
10256
- elementStateChanged: domVerified,
10257
- isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10258
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url),
10405
+ elementStateChanged: domVerified && destinationPlausible,
10406
+ isSubmitShapedStep: isStepSubmitShaped({
10407
+ step,
10408
+ submitStep,
10409
+ isFinalStep,
10410
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10411
+ }),
10412
+ destinationPlausible,
10259
10413
  });
10260
10414
  // An `"effective"` verdict driven purely by the page-wide byte-delta
10261
10415
  // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
@@ -10276,8 +10430,12 @@ async function executeStepWithHealing(params) {
10276
10430
  // respects the SAME DOM-only-advance veto as the block above: a wizard
10277
10431
  // "Next" step on a pattern-configured site whose only signal is a field
10278
10432
  // toggle must stay unverified, not get waved through by the verdict.
10279
- const domEffectiveVerdict = !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)) &&
10280
- domVerifiedForStep;
10433
+ const domEffectiveVerdict = !isStepSubmitShaped({
10434
+ step,
10435
+ submitStep,
10436
+ isFinalStep,
10437
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10438
+ }) && domVerifiedForStep;
10281
10439
  let verified = networkIsRealAdvance ||
10282
10440
  urlChanged ||
10283
10441
  domVerifiedForStep ||
@@ -10492,11 +10650,15 @@ async function executeStepWithHealing(params) {
10492
10650
  // tail node `clickExpr` below is about to click, instead of only
10493
10651
  // ever checking the (possibly unresolvable) primary xpath — see
10494
10652
  // resolvedClickTargetIsSubmitShaped's doc comment.
10495
- const retryResolvedElementIsSubmitShaped = await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
10653
+ const retryResolvedElementIsSubmitShaped = stepDeclaredNonSubmit
10654
+ ? false
10655
+ : await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
10496
10656
  // See resolvedClickUsedXpathTailRetarget's doc comment: flags that
10497
10657
  // this click's target identity came only from the loose tail
10498
10658
  // heuristic, not Stagehand's own resolved selector.
10499
- const retryClickUsedXpathTailRetarget = await resolvedClickUsedXpathTailRetarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
10659
+ const retryClickUsedXpathTailRetarget = stepDeclaredNonSubmit
10660
+ ? false
10661
+ : await resolvedClickUsedXpathTailRetarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
10500
10662
  // Trusted-click delivery for the plain-click branch: attempted
10501
10663
  // BEFORE the resolve/activate expression below (so a successful
10502
10664
  // delivery can suppress the synthetic `clickActivationExpr`
@@ -10528,7 +10690,7 @@ async function executeStepWithHealing(params) {
10528
10690
  const n16TrustedClickFailure = !n16TrustedDelivered && "reason" in n16TrustedClickResult
10529
10691
  ? n16TrustedClickResult
10530
10692
  : null;
10531
- const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el && ${JSON.stringify(xpathTail)}) { el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}); } if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
10693
+ const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el && ${JSON.stringify(xpathTail)}) { el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}); } if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
10532
10694
  const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10533
10695
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
10534
10696
  const fired = probeResult.fired;
@@ -10547,7 +10709,7 @@ async function executeStepWithHealing(params) {
10547
10709
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10548
10710
  let node = r.singleNodeValue;
10549
10711
  if (!node && ${JSON.stringify(xpathTail)}) {
10550
- node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
10712
+ node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)});
10551
10713
  }
10552
10714
  if (!node) return false;
10553
10715
  for (let depth = 0; depth < 6 && node; depth++) {
@@ -10631,7 +10793,7 @@ async function executeStepWithHealing(params) {
10631
10793
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10632
10794
  let el = r.singleNodeValue;
10633
10795
  if (!el && ${JSON.stringify(xpathTail)}) {
10634
- el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
10796
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)});
10635
10797
  }
10636
10798
  return el ? isDisabled(el) : false;
10637
10799
  })()`;
@@ -10659,7 +10821,11 @@ async function executeStepWithHealing(params) {
10659
10821
  // Poll for the real TransitionWorklet(type="next") like the primary
10660
10822
  // verifier — the transition POST can land after this snapshot, and a
10661
10823
  // one-shot check would false-negative and retry into a back-bounce.
10824
+ // Destination-gated so an implausible landing URL's traffic cannot satisfy
10825
+ // the veto or the submit-transition carve-out downstream.
10826
+ const retryDestinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
10662
10827
  const retryNetworkIsRealAdvance = retryNetworkFired &&
10828
+ retryDestinationPlausible &&
10663
10829
  (await waitForTransitionBody({
10664
10830
  page,
10665
10831
  preIdx: preCaptureIdx,
@@ -10671,7 +10837,12 @@ async function executeStepWithHealing(params) {
10671
10837
  }));
10672
10838
  const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
10673
10839
  hasPattern: advanceTransitionBodyPattern !== null,
10674
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10840
+ isFinalOrSubmit: isStepSubmitShaped({
10841
+ step,
10842
+ submitStep,
10843
+ isFinalStep,
10844
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10845
+ }),
10675
10846
  isAdvance: isAdvanceStep(step),
10676
10847
  retryUrlChanged,
10677
10848
  retryNetworkIsRealAdvance,
@@ -10730,31 +10901,63 @@ async function executeStepWithHealing(params) {
10730
10901
  // step resolving onto an objectively submit-shaped control is
10731
10902
  // treated identically to an explicitly flagged one everywhere this
10732
10903
  // value is read below.
10733
- const retrySubmitShaped = submitStep ||
10734
- (isFinalStep && flowHasSubmitSemanticsFlag) ||
10735
- retryResolvedElementIsSubmitShaped ||
10736
- isSubmitIntentStep(step);
10737
- const weakDomSignalsAllowed = ((!isFinalStep && !submitStep && !retryResolvedElementIsSubmitShaped) ||
10904
+ const retrySubmitShaped = isStepSubmitShaped({
10905
+ step,
10906
+ submitStep,
10907
+ isFinalStep,
10908
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10909
+ }) || retryResolvedElementIsSubmitShaped;
10910
+ const weakDomSignalsAllowed = ((!(isFinalStep && !stepDeclaredNonSubmit) &&
10911
+ !submitStep &&
10912
+ !retryResolvedElementIsSubmitShaped) ||
10738
10913
  requireSubmitEndpoint) &&
10739
10914
  !isCheckboxOrRadioIntentStep(step) &&
10740
10915
  !clickTargetIsSelectionMarker;
10916
+ // `classifyPhantomClick`'s own `elementStateChanged` input is
10917
+ // UNGATED by `destinationPlausible` (phantom-click.ts ~220-229);
10918
+ // the primary attempt gates its credit paths (`domVerifiedForStep`,
10919
+ // `networkIsRealAdvance`) on destination itself. The n+16 fallback's
10920
+ // `retrySelectionStateChanged`, UNLIKE the primary technique's
10921
+ // `domVerified`, is partly driven by this fallback's OWN synthetic
10922
+ // `el.click()`/forced-checkbox probe rather than a genuine site
10923
+ // click handler — pre-gating it here (this call site only; the
10924
+ // primary technique's call above is untouched) closes the n+16-
10925
+ // specific path where `classifyPhantomClick`'s `"effective"`
10926
+ // verdict would otherwise re-credit a destination-implausible
10927
+ // click purely off `elementStateChanged`, bypassing the direct
10928
+ // `retrySelectionStateChanged`/`checkboxStateVerified` gates below.
10741
10929
  const retryVerdict = (0, phantom_click_1.classifyPhantomClick)({
10742
10930
  actResultSuccess: record.actResultSuccess,
10743
10931
  pre,
10744
10932
  post: retryPost,
10745
10933
  elementStateChanged: retrySelectionStateChanged,
10746
10934
  isSubmitShapedStep: retrySubmitShaped,
10747
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url),
10935
+ destinationPlausible: retryDestinationPlausible,
10748
10936
  });
10937
+ // Destination-plausibility audit of every remaining disjunct in
10938
+ // this OR (the recon report's closing instruction, see
10939
+ // flow-runner.retry-fallback-remaining-disjuncts-destination-
10940
+ // audit.test.ts): `retryNetworkFired` is a bare request COUNT with
10941
+ // zero awareness of destination (a request can fire en route to
10942
+ // ANY page, including a wrong one); `checkboxStateVerified` forces
10943
+ // `.checked = true` on whatever element the xpath resolves to on
10944
+ // the CURRENTLY LOADED page, independent of whether the click
10945
+ // fallback navigated there correctly; `retrySelectionStateChanged`
10946
+ // reads a fingerprint for whatever element the xpath/selector
10947
+ // resolves to post-click, same independence from destination. All
10948
+ // three get the same `isPlausibleStepDestination` gate
10949
+ // `weakDomSignalsAllowed` already has (bugfix-002) — `retryUrlChanged`
10950
+ // already folds the same check in at its own computation above.
10749
10951
  let retryVerified = !clickBlockedByDisabled &&
10750
10952
  !clickBlockedByInvalid &&
10751
10953
  !fallbackDomOnlyAdvance &&
10752
- (retryNetworkFired ||
10954
+ ((retryNetworkFired && retryDestinationPlausible) ||
10753
10955
  retryUrlChanged ||
10754
- checkboxStateVerified ||
10755
- retrySelectionStateChanged ||
10956
+ (checkboxStateVerified && retryDestinationPlausible) ||
10957
+ (retrySelectionStateChanged && retryDestinationPlausible) ||
10756
10958
  (!retrySubmitShaped && retryVerdict === "effective") ||
10757
10959
  (weakDomSignalsAllowed &&
10960
+ retryDestinationPlausible &&
10758
10961
  (retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
10759
10962
  if (retryVerified) {
10760
10963
  record.phantomClickVerdict = retryVerdict;
@@ -10797,7 +11000,11 @@ async function executeStepWithHealing(params) {
10797
11000
  // flow-runner.overlay-hidden-target-trusted-click-acceptance.test.ts
10798
11001
  // and flow-runner.evidence-table-alternating-verdict-regression.test.ts,
10799
11002
  // both of which rely on that carve-out and must stay green).
10800
- const retryDestinationUnconfirmed = isFinalStep && !submitStep && retryUrlChanged && !retryResolvedElementIsSubmitShaped;
11003
+ const retryDestinationUnconfirmed = isFinalStep &&
11004
+ !submitStep &&
11005
+ !stepDeclaredNonSubmit &&
11006
+ retryUrlChanged &&
11007
+ !retryResolvedElementIsSubmitShaped;
10801
11008
  if (retryVerified && retryDestinationUnconfirmed && !requireSubmitEndpoint) {
10802
11009
  // Veto directly rather than routing through the judge below: no
10803
11010
  // endpoint pattern and no submit-shaped element means there is
@@ -10885,7 +11092,18 @@ async function executeStepWithHealing(params) {
10885
11092
  logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} formValueChanged=${retryFormValueChanged} selectionStateChanged=${retrySelectionStateChanged} verified=${retryVerified} delivery=${n16DeliveryBranch}${n16TrustedClickFailureSuffix}`);
10886
11093
  if (retryVerified) {
10887
11094
  if (record.verifiedBy === null) {
10888
- record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
11095
+ // Mirror the same `retryDestinationPlausible` gate `retryVerified`
11096
+ // applies to `retryNetworkFired` above — otherwise a step verified
11097
+ // via `retryVerdict === "effective"` (or `weakDomSignalsAllowed`)
11098
+ // while the destination is implausible would still be labeled
11099
+ // "network" here off the raw, ungated flag, misreporting what
11100
+ // actually verified it and desyncing `verifiedBy` consumers (e.g.
11101
+ // the `priorDomVerified` check below) from the real gate.
11102
+ record.verifiedBy = retryUrlChanged
11103
+ ? "url"
11104
+ : retryNetworkFired && retryDestinationPlausible
11105
+ ? "network"
11106
+ : "dom";
10889
11107
  }
10890
11108
  record.post = retryPost;
10891
11109
  attempts.push(record);
@@ -10957,7 +11175,12 @@ async function executeStepWithHealing(params) {
10957
11175
  // dumps in a 2026-06-10 survey had the paired touched+dirty + visible
10958
11176
  // error text pattern with 3 distinct rejection messages.
10959
11177
  if (record.resolvedMethod === "click" &&
10960
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
11178
+ isStepSubmitShaped({
11179
+ step,
11180
+ submitStep,
11181
+ isFinalStep,
11182
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11183
+ })) {
10961
11184
  const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
10962
11185
  client: anthropic,
10963
11186
  knownErrorClassPrefixes,
@@ -11018,7 +11241,12 @@ async function executeStepWithHealing(params) {
11018
11241
  // (triedSelectors), which is empty on an "unresolved" verdict, so a
11019
11242
  // non-submit step with that verdict falls through to the normal
11020
11243
  // ladder instead of escalating.
11021
- const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
11244
+ const escalationTarget = isStepSubmitShaped({
11245
+ step,
11246
+ submitStep,
11247
+ isFinalStep,
11248
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11249
+ })
11022
11250
  ? "escalating attempt 2 to deep-submit-locator"
11023
11251
  : phantomClickAfterAttempt1
11024
11252
  ? "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)"
@@ -11030,7 +11258,12 @@ async function executeStepWithHealing(params) {
11030
11258
  // Treat the canonical submit click as "final" for this predicate
11031
11259
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
11032
11260
  // above for the same gate-widening rationale.
11033
- isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
11261
+ isFinalStep: isStepSubmitShaped({
11262
+ step,
11263
+ submitStep,
11264
+ isFinalStep,
11265
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11266
+ }),
11034
11267
  requireSubmitEndpoint,
11035
11268
  resolvedMethod: record.resolvedMethod,
11036
11269
  effectSignals,
@@ -11050,7 +11283,12 @@ async function executeStepWithHealing(params) {
11050
11283
  // can reorder a later step forward — instead of burning the cascade.
11051
11284
  const advanceStalled = isAdvanceStalled({
11052
11285
  isAdvance: isAdvanceStep(step),
11053
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
11286
+ isFinalOrSubmit: isStepSubmitShaped({
11287
+ step,
11288
+ submitStep,
11289
+ isFinalStep,
11290
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11291
+ }),
11054
11292
  hasPattern: advanceTransitionBodyPattern !== null,
11055
11293
  clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
11056
11294
  networkFired,
@@ -11077,8 +11315,13 @@ async function executeStepWithHealing(params) {
11077
11315
  // firing on a non-submit step could complete it by clicking an unrelated
11078
11316
  // submit control in an unrelated same-origin child iframe.
11079
11317
  if (!frameTarget?.declaredFrameSelector &&
11080
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
11081
- const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter });
11318
+ isStepSubmitShaped({
11319
+ step,
11320
+ submitStep,
11321
+ isFinalStep,
11322
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11323
+ })) {
11324
+ const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter, step });
11082
11325
  if (fallbackTarget) {
11083
11326
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} cascade-exhausted: no candidate resolvable in the declared/main frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
11084
11327
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
@@ -11110,7 +11353,12 @@ async function executeStepWithHealing(params) {
11110
11353
  const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
11111
11354
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
11112
11355
  : cascadeExhaustObservedUnfocused;
11113
- const targetResolutionDiagnostic = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
11356
+ const targetResolutionDiagnostic = isStepSubmitShaped({
11357
+ step,
11358
+ submitStep,
11359
+ isFinalStep,
11360
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11361
+ })
11114
11362
  ? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page, lastRankedSubmitCandidates)
11115
11363
  : null;
11116
11364
  const dumpPath = onStepFailure?.({
@@ -11255,6 +11503,7 @@ async function runHealingFlow(deps) {
11255
11503
  stepPromise = executeStepWithHealing({
11256
11504
  stagehand,
11257
11505
  page,
11506
+ abortSignal: deps.abortSignal,
11258
11507
  step: s.instruction,
11259
11508
  optional: s.optional,
11260
11509
  upload: s.upload,