@enricai/barnacle 1.12.54 → 1.12.56

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 +449 -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 +440 -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 +389 -51
  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,51 @@ async function waitForCaptchaNavigation(params) {
4564
4586
  }
4565
4587
  return false;
4566
4588
  }
4589
+ /**
4590
+ * Stagehand's `StagehandEvalError` message when CDP's `Runtime.evaluate`
4591
+ * reports an exception with no `exception.description` to fall back on:
4592
+ * `exceptionDetails.text` is a content-free classification string ("Uncaught",
4593
+ * "Uncaught (in promise)") Chrome sets on every thrown/rejected evaluate
4594
+ * regardless of what was actually thrown, and Stagehand's own placeholder
4595
+ * ("Evaluation failed") when neither field is present at all. None of these
4596
+ * carry any evidence about what happened in-page — they fire for a clean
4597
+ * teardown-adjacent throw exactly as often as a genuine bug — so a rejection
4598
+ * whose full message is exactly one of these (after bugfix-001's page.js/
4599
+ * frame.js patch already preferred the real `description` when Chrome
4600
+ * provided one) is content-free, not evidence of a real exception.
4601
+ */
4602
+ const GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES = new Set([
4603
+ "stagehandevalerror: uncaught",
4604
+ "stagehandevalerror: uncaught (in promise)",
4605
+ "stagehandevalerror: evaluation failed",
4606
+ ]);
4607
+ /**
4608
+ * Distinguishes a navigating evaluate's expected context-teardown rejection
4609
+ * (the callback/submit dispatch it wraps synchronously navigated the frame,
4610
+ * tearing down the execution context before a return value marshals) — or a
4611
+ * rejection whose message carries no diagnostic content beyond Stagehand's
4612
+ * generic classification wrapper (see
4613
+ * {@link GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES}) — from a genuine
4614
+ * in-page exception with real diagnostic detail: a thrown `TypeError`, a
4615
+ * rejected fetch, or any other real failure inside the site's own
4616
+ * callback/submit logic. All three are indistinguishable to a blanket
4617
+ * `.catch(() => undefined)`, which is exactly why a genuinely
4618
+ * thrown-and-swallowed exception during the callback invoke used to log
4619
+ * `callbackDiscovered=true`/`injected=true` (both computed BEFORE the
4620
+ * invoke) while never actually dispatching anything. Only a context-teardown
4621
+ * or content-free rejection is safe to discard silently; this is what lets
4622
+ * callers keep doing that for those cases while surfacing genuine detail.
4623
+ */
4624
+ function isNavigatingEvaluateRejection(error) {
4625
+ const message = (0, errors_1.toErrorMessage)(error).toLowerCase();
4626
+ return (message.includes("execution context was destroyed") ||
4627
+ message.includes("execution context is not available") ||
4628
+ message.includes("cannot find context with specified id") ||
4629
+ message.includes("target closed") ||
4630
+ message.includes("frame was detached") ||
4631
+ message.includes("no frame for given id found") ||
4632
+ GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES.has(message.trim()));
4633
+ }
4567
4634
  /**
4568
4635
  * Shared in-page discovery of the widget's callback, used identically by
4569
4636
  * `injectCaptchaTokenAndSubmit`'s precheck (to decide the branch) and its
@@ -4616,6 +4683,10 @@ function setResponseFieldAndInvokeCallbackExprSrc(responseField, token) {
4616
4683
  } else if (field) {
4617
4684
  field.value = token;
4618
4685
  }
4686
+ if (field) {
4687
+ field.dispatchEvent(new Event("input", { bubbles: true }));
4688
+ field.dispatchEvent(new Event("change", { bubbles: true }));
4689
+ }
4619
4690
  found.invoke(token);
4620
4691
  `;
4621
4692
  }
@@ -4707,8 +4778,8 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4707
4778
  // case: it's an anonymous closure living in the registry, not a named
4708
4779
  // `window` global, so it cannot be marshalled back from the precheck's
4709
4780
  // evaluate call.
4710
- await target.evaluate(invokeCallbackExpr).catch(() => undefined);
4711
- return { injected, hasForm, callbackDiscovered };
4781
+ const callbackInvokeError = await target.evaluate(invokeCallbackExpr).then(() => undefined, (err) => (isNavigatingEvaluateRejection(err) ? undefined : (0, errors_1.toErrorMessage)(err)));
4782
+ return { injected, hasForm, callbackDiscovered, callbackInvokeError };
4712
4783
  }
4713
4784
  // Belt-and-suspenders: this frame missed session.ts's page-init-script
4714
4785
  // install and bugfix-001's per-frame attach install (it either attached
@@ -4739,8 +4810,8 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4739
4810
  // the non-late invoke above: the callback is free to submit the form
4740
4811
  // synchronously off the field value that's now present, tearing down the
4741
4812
  // execution context before a return value marshals.
4742
- await target.evaluate(invokeLateCallbackExpr).catch(() => undefined);
4743
- return { injected, hasForm, callbackDiscovered: true };
4813
+ const callbackInvokeError = await target.evaluate(invokeLateCallbackExpr).then(() => undefined, (err) => (isNavigatingEvaluateRejection(err) ? undefined : (0, errors_1.toErrorMessage)(err)));
4814
+ return { injected, hasForm, callbackDiscovered: true, callbackInvokeError };
4744
4815
  }
4745
4816
  const setValueExpr = `(() => {
4746
4817
  const responseField = ${JSON.stringify(responseField)};
@@ -4773,7 +4844,10 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4773
4844
  const dispatchChangeExpr = `(() => {
4774
4845
  const responseField = ${JSON.stringify(responseField)};
4775
4846
  const field = document.querySelector('[name="' + responseField + '"]');
4776
- if (field) field.dispatchEvent(new Event("change", { bubbles: true }));
4847
+ if (field) {
4848
+ field.dispatchEvent(new Event("input", { bubbles: true }));
4849
+ field.dispatchEvent(new Event("change", { bubbles: true }));
4850
+ }
4777
4851
  })()`;
4778
4852
  // Dispatching "change" can trigger a page's own submit-on-token callback,
4779
4853
  // which navigates the frame synchronously from within this call and tears
@@ -4788,24 +4862,123 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4788
4862
  * used only when the caller's own transition poll observed no advance after
4789
4863
  * the inject, so the widget's callback (if any) evidently didn't submit for
4790
4864
  * 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") {
4865
+ * on an observed transition rather than firing it unconditionally.
4866
+ *
4867
+ * Dispatches a real, framework-observed click via the same
4868
+ * {@link buildRankSubmitCandidatesExpr}/{@link buildClickByDeepIndexExpr}
4869
+ * primitives the phantom-click cascade uses, rather than a synthetic
4870
+ * `form.requestSubmit()`/`form.submit()` call: on a React/SPA site the
4871
+ * submit button's `onClick` handler owns the actual submission (validation,
4872
+ * XHR/fetch dispatch, etc.), and `<form>`-level APIs never fire it, so
4873
+ * "success" here would silently produce zero site-host HTTP traffic for
4874
+ * {@link waitForCaptchaNavigation} to observe. Only when no submit-shaped
4875
+ * control can be found or clicked does this fall back to the form-level API
4876
+ * — a plain server-rendered form with no button-bound JS handler still needs
4877
+ * `requestSubmit()`/`submit()` to actually navigate.
4878
+ *
4879
+ * Resolves the form to submit by name first (the response field's own
4880
+ * closest form), then falls back to the sitekey-anchored form (or the sole
4881
+ * form on the page) the same way {@link injectCaptchaTokenAndSubmit} resolves
4882
+ * its target: an invisible/callback-only widget never creates a named
4883
+ * response field, so a named-field-only lookup here would silently no-op and
4884
+ * no site-host traffic would ever follow the clean callback. Resolves to
4885
+ * whether a form was actually found and acted on; a true no-op is reported
4886
+ * only when the page has no form at all to resolve.
4887
+ *
4888
+ * The click-dispatch and form-level submit evals both tolerate the expected
4889
+ * context-teardown rejection of a click/submit that navigated the frame —
4890
+ * see {@link isNavigatingEvaluateRejection} — but rethrow anything else, so a
4891
+ * genuine in-page exception during the dispatch propagates to the caller
4892
+ * instead of silently resolving as a successful submit.
4893
+ *
4894
+ * The ranked-candidate click is verified the same way the main cascade's
4895
+ * `deep-submit-locator` verifies its top pick (see `executeStepWithHealing`
4896
+ * at flow-runner.ts:10374-10428): a shadow-root/web-component control can
4897
+ * report `clicked: true` while wiring no real handler, so a bare
4898
+ * `clicked: true` here would let a phantom click masquerade as a resolved
4899
+ * submit and this fallback would return `true` with zero site-host traffic
4900
+ * ever following. When `verification` is supplied, snapshots pre/post via
4901
+ * `snapshotPage` and classifies with `classifyPhantomClick({isSubmitShapedStep:
4902
+ * true})`; a `phantom` verdict retries the runner-up candidate once (mirroring
4903
+ * the cascade's single runner-up retry) before falling through to the
4904
+ * form-level submit below. `verification` is optional so callers that only
4905
+ * need the non-polling, non-verifying original contract (and existing tests
4906
+ * exercising the rank/click primitives directly) are unaffected.
4907
+ */
4908
+ async function submitCaptchaGatedForm(target, responseField = "h-captcha-response", verification) {
4909
+ const findFormExpr = `(() => {
4910
+ const responseField = ${JSON.stringify(responseField)};
4911
+ const field = document.querySelector('[name="' + responseField + '"]');
4912
+ const fieldForm = field ? field.closest("form") : null;
4913
+ if (fieldForm) return true;
4914
+ const forms = Array.from(document.querySelectorAll("form"));
4915
+ const sitekeyForm = forms.find((candidate) => candidate.querySelector("[data-sitekey]")) ?? forms[0];
4916
+ return Boolean(sitekeyForm);
4917
+ })()`;
4918
+ const found = await target.evaluate(findFormExpr).catch(() => false);
4919
+ if (!found)
4920
+ return false;
4921
+ const rankResult = await target
4922
+ .evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)())
4923
+ .catch(() => []);
4924
+ const ranked = Array.isArray(rankResult) ? rankResult : [];
4925
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the length check
4926
+ const top = ranked.length > 0 ? ranked[0] : null;
4927
+ if (top) {
4928
+ const pre = verification
4929
+ ? await snapshotPage(target, verification.signalCounter, verification.page)
4930
+ : null;
4931
+ const clickResult = await target
4932
+ .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))
4933
+ .catch((err) => {
4934
+ if (isNavigatingEvaluateRejection(err))
4935
+ return { clicked: false };
4936
+ throw err;
4937
+ });
4938
+ if (clickResult.clicked) {
4939
+ if (!pre || !verification)
4940
+ return true;
4941
+ const post = await snapshotPage(target, verification.signalCounter, verification.page);
4942
+ const verdict = (0, phantom_click_1.classifyPhantomClick)({
4943
+ actResultSuccess: true,
4944
+ pre,
4945
+ post,
4946
+ isSubmitShapedStep: true,
4947
+ });
4948
+ if (verdict !== "phantom")
4949
+ return true;
4950
+ const runnerUp = ranked[1];
4951
+ if (runnerUp) {
4952
+ const runnerUpClickResult = await target
4953
+ .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
4954
+ .catch((err) => {
4955
+ if (isNavigatingEvaluateRejection(err))
4956
+ return { clicked: false };
4957
+ throw err;
4958
+ });
4959
+ if (runnerUpClickResult.clicked)
4960
+ return true;
4961
+ }
4962
+ }
4963
+ }
4798
4964
  const submitExpr = `(() => {
4799
4965
  const responseField = ${JSON.stringify(responseField)};
4800
4966
  const field = document.querySelector('[name="' + responseField + '"]');
4801
- const form = field ? field.closest("form") : null;
4967
+ const fieldForm = field ? field.closest("form") : null;
4968
+ const forms = Array.from(document.querySelectorAll("form"));
4969
+ const form = fieldForm ?? forms.find((candidate) => candidate.querySelector("[data-sitekey]")) ?? forms[0];
4802
4970
  if (form) (form.requestSubmit ? form.requestSubmit() : form.submit());
4803
4971
  })()`;
4804
4972
  // form.submit() navigates the frame synchronously, tearing down the execution
4805
4973
  // context before Runtime.evaluate can marshal a return value for this call —
4806
4974
  // that rejection is the expected outcome of a navigating evaluate, not a
4807
4975
  // real failure, so it's discarded here rather than awaited for a result.
4808
- await target.evaluate(submitExpr).catch(() => undefined);
4976
+ await target.evaluate(submitExpr).catch((err) => {
4977
+ if (isNavigatingEvaluateRejection(err))
4978
+ return undefined;
4979
+ throw err;
4980
+ });
4981
+ return true;
4809
4982
  }
4810
4983
  /**
4811
4984
  * Site-agnostic select primitive: answer a native `<select>` dropdown by
@@ -7759,7 +7932,7 @@ async function executeStepWithHealing(params) {
7759
7932
  // instead of silently falling through as if unverified-but-passing.
7760
7933
  const isSubmitOrFinalStep = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag);
7761
7934
  if (captchaGated && isSubmitOrFinalStep) {
7762
- const captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
7935
+ let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
7763
7936
  const sitekeyProbeExpr = `(() => {
7764
7937
  const el = document.querySelector("[data-sitekey]");
7765
7938
  if (!el) return { siteKey: null, isInvisible: false };
@@ -7787,26 +7960,56 @@ async function executeStepWithHealing(params) {
7787
7960
  // no-nav case is the same underlying race from the other side.
7788
7961
  for (let captchaAttempt = 1; captchaAttempt <= CAPTCHA_REGISTRY_RETRY_ATTEMPTS; captchaAttempt++) {
7789
7962
  if (captchaAttempt > 1) {
7963
+ // Re-resolve the target frame before every retry, mirroring the
7964
+ // main step loop's per-step `resolveFrameTarget` call: a
7965
+ // navigation or detach triggered by the prior attempt (its solve's
7966
+ // ~120s wait, its inject, or its submit) can leave `captchaTarget`
7967
+ // bound to a torn-down frame, and every evaluate below would
7968
+ // otherwise silently keep operating on that stale target for the
7969
+ // rest of the retries. A target with no declared frame selector
7970
+ // (main-frame flows) resolves this immediately with zero polling.
7971
+ captchaTarget = await (0, frame_target_1.resolveFrameTarget)(page, captchaTarget.frameSelector ?? captchaTarget.declaredFrameSelector ?? null);
7790
7972
  // Re-assert the capture install in the target frame before
7791
7973
  // retrying: a late-attaching registry may not exist yet on the
7792
7974
  // first attempt, and re-running this idempotent install gives a
7793
7975
  // widget that renders on demand another chance to be caught.
7794
7976
  await captchaTarget.evaluate((0, captcha_callback_capture_1.buildHcaptchaCallbackCaptureScript)()).catch(() => undefined);
7795
7977
  }
7796
- const solved = await (0, captcha_solver_1.solveCaptcha)({
7978
+ const solveResult = await (0, captcha_solver_1.solveCaptcha)({
7797
7979
  type: "hcaptcha",
7798
7980
  siteKey,
7799
7981
  pageUrl,
7800
7982
  isInvisible,
7801
7983
  userAgent,
7802
7984
  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
- });
7985
+ })
7986
+ .then((solved) => ({ ok: true, solved }))
7987
+ .catch((err) => ({ ok: false, err }));
7988
+ if (!solveResult.ok) {
7989
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
7990
+ 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"}`);
7991
+ if (attemptsRemain)
7992
+ continue;
7993
+ throw solveResult.err;
7994
+ }
7995
+ const solved = solveResult.solved;
7807
7996
  const preCaptchaCaptureIdx = latestCaptureIndex(recentCaptures);
7808
- const injectResult = await injectCaptchaTokenAndSubmit(captchaTarget, solved.token);
7809
- const registryState = await captchaTarget.evaluate(`(() => {
7997
+ const preCaptchaMetaLength = recentCaptureMeta.length;
7998
+ // Both the inject-and-submit primitive and the registry-state probe
7999
+ // below evaluate against `captchaTarget` after the solve's ~120s poll
8000
+ // has already elapsed, so a page navigation or frame detach that
8001
+ // happened during that wait can leave the frame stale/wedged and
8002
+ // make either evaluate throw via `withWatchdog` — a possibility
8003
+ // distinct from (and unguarded by) the solveCaptcha rejection handled
8004
+ // above. Fold that into the same attempts-remaining tolerance rather
8005
+ // than letting it escape the loop uncaught on every attempt: retry
8006
+ // when attempts remain, still throw on the final attempt so a
8007
+ // genuine failure fails the step per the comment below.
8008
+ let injectResult;
8009
+ let registryState;
8010
+ try {
8011
+ injectResult = await injectCaptchaTokenAndSubmit(captchaTarget, solved.token);
8012
+ registryState = await captchaTarget.evaluate(`(() => {
7810
8013
  const sitekey = ${JSON.stringify(siteKey)};
7811
8014
  const findSitekeyEl = function () {
7812
8015
  return Array.prototype.find.call(
@@ -7832,6 +8035,14 @@ async function executeStepWithHealing(params) {
7832
8035
  if (hcaptchaLoaded && widgetRendered) return "renderedUnmatched";
7833
8036
  return "empty";
7834
8037
  })()`);
8038
+ }
8039
+ catch (err) {
8040
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
8041
+ 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"}`);
8042
+ if (attemptsRemain)
8043
+ continue;
8044
+ throw err;
8045
+ }
7835
8046
  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
8047
  // Reuse the EXISTING waitForTransitionBody poll (no new captcha-specific
7837
8048
  // poll loop), just with a widened budget so the solve+submit round trip
@@ -7856,9 +8067,32 @@ async function executeStepWithHealing(params) {
7856
8067
  // only issue an explicit submit when no transition was observed AND a
7857
8068
  // form is known to exist to submit — otherwise this would double-submit
7858
8069
  // a form the widget's own callback already advanced.
8070
+ let fallbackSubmitted = false;
7859
8071
  if (!confirmed && injectResult.hasForm) {
7860
- await submitCaptchaGatedForm(captchaTarget);
7861
- if (advanceTransitionBodyPattern) {
8072
+ try {
8073
+ fallbackSubmitted = await submitCaptchaGatedForm(captchaTarget, "h-captcha-response", {
8074
+ signalCounter,
8075
+ page,
8076
+ });
8077
+ }
8078
+ catch (err) {
8079
+ // A genuine in-page exception from the click/submit dispatch
8080
+ // (classified via `isNavigatingEvaluateRejection` inside
8081
+ // `submitCaptchaGatedForm`) — not the expected context-teardown
8082
+ // rejection of a submit that navigated the frame. Fold into the
8083
+ // same attempts-remaining tolerance as the inject/registry-probe
8084
+ // catch above rather than letting it silently resolve as a
8085
+ // no-op fallback.
8086
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
8087
+ 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"}`);
8088
+ if (attemptsRemain)
8089
+ continue;
8090
+ throw err;
8091
+ }
8092
+ if (!fallbackSubmitted) {
8093
+ 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`);
8094
+ }
8095
+ else if (advanceTransitionBodyPattern) {
7862
8096
  confirmed = await waitForTransitionBody({
7863
8097
  page,
7864
8098
  preIdx: preCaptchaCaptureIdx,
@@ -7891,13 +8125,53 @@ async function executeStepWithHealing(params) {
7891
8125
  trajectory?.push({ stepIndex, verifiedBy: "url" });
7892
8126
  return "completed";
7893
8127
  }
8128
+ // Neither transition poll above can see a same-origin submit whose
8129
+ // response never changes the page's URL (an XHR/fetch-driven submit
8130
+ // with no client-side redirect). `findRecentPageTransition` is the
8131
+ // SAME network-capture detector the probe-absent path below already
8132
+ // relies on for exactly this case — reusing it here, scoped to the
8133
+ // captures landed since this attempt's solve, catches the submit a
8134
+ // clean callback dispatches even when the URL/origin never moves.
8135
+ const networkTransitionUrl = findRecentPageTransition({
8136
+ recentCaptureMeta,
8137
+ preMetaLength: preCaptchaMetaLength,
8138
+ });
8139
+ if (networkTransitionUrl !== null) {
8140
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-submit network response confirmed the advance (${networkTransitionUrl})`);
8141
+ trajectory?.push({ stepIndex, verifiedBy: "network" });
8142
+ return "completed";
8143
+ }
8144
+ // The discovered callback threw a genuine in-page exception during
8145
+ // its invoke (classified via `isNavigatingEvaluateRejection`), and
8146
+ // none of the transition detectors above confirmed an advance
8147
+ // either. Checked BEFORE `shouldRetryCaptchaRegistry` below so the
8148
+ // swallowed exception is always logged and threaded into the retry
8149
+ // decision — never silently absorbed into that check's generic
8150
+ // "callback fired but no nav" retry reasoning, which says nothing
8151
+ // about a real thrown error having occurred. A thrown exception is
8152
+ // direct evidence the token was never actually delivered, not merely
8153
+ // an absence of a poll result, so it must never be folded into the
8154
+ // "clean callback, no evidence either way" fallthrough further below
8155
+ // either. Retry while attempts remain (the exception may be
8156
+ // transient, e.g. a stale frame on this attempt — re-resolved at the
8157
+ // top of the next iteration), else fail loudly naming the real cause
8158
+ // instead of silently reporting `callbackDiscovered=true`/
8159
+ // `injected=true` and falling through to the phantom-click cascade
8160
+ // with no trace of it.
8161
+ if (injectResult.callbackInvokeError) {
8162
+ const attemptsRemain = captchaAttempt < CAPTCHA_REGISTRY_RETRY_ATTEMPTS;
8163
+ 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"}`);
8164
+ if (attemptsRemain)
8165
+ continue;
8166
+ 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`);
8167
+ }
7894
8168
  // The intermittent race this loop exists for: a still-empty/absent
7895
8169
  // registry means the callback attach hadn't landed when this attempt
7896
8170
  // solved+injected, and a discovered-but-unconfirmed callback means it
7897
8171
  // fired without a poll seeing the resulting transition. Both are
7898
8172
  // worth another attempt within budget rather than an immediate throw.
7899
8173
  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`);
8174
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: registryState=${registryState} callbackDiscovered=${injectResult.callbackDiscovered} fallbackSubmitted=${fallbackSubmitted} with no confirmed transition on attempt ${captchaAttempt}; retrying`);
7901
8175
  continue;
7902
8176
  }
7903
8177
  // No render-config callback was discoverable AND neither the inject's
@@ -7911,9 +8185,25 @@ async function executeStepWithHealing(params) {
7911
8185
  // none configured, no network poll ever ran, so there's nothing here
7912
8186
  // to contradict the normal cascade/verifier.
7913
8187
  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`);
8188
+ const fallbackDetail = injectResult.hasForm && !fallbackSubmitted
8189
+ ? " and the explicit submit fallback found no field/form to act on"
8190
+ : "";
8191
+ 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
8192
  }
7916
- break;
8193
+ // The remaining shape: a render-config callback WAS cleanly
8194
+ // discovered and invoked on every attempt (or no pattern was
8195
+ // configured to distinguish that from the branch above), yet
8196
+ // neither the navigation poll nor the network-capture scan ever
8197
+ // confirmed an advance on any attempt. `shouldRetryCaptchaRegistry`
8198
+ // above already exhausted every attempt worth retrying, so this is
8199
+ // not a registry race — the token was solved and handed to the
8200
+ // widget's own callback, but nothing observable ever moved. Fail
8201
+ // loudly here too instead of `break`-ing into the normal
8202
+ // phantom-click cascade as if the solve had worked; that fallthrough
8203
+ // is the exact misleading "callbackDiscovered=true" silent no-op
8204
+ // this hook must never produce, pattern-configured or not.
8205
+ 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`);
8206
+ throw new errors_2.CaptchaError(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: registryState=${registryState} callbackDiscovered=${injectResult.callbackDiscovered} produced no confirmed transition after exhausting all attempts`);
7917
8207
  }
7918
8208
  }
7919
8209
  }
@@ -9210,9 +9500,7 @@ async function executeStepWithHealing(params) {
9210
9500
  // ng-invalid marker count grew (see isClickViewSwapVerified's doc comment).
9211
9501
  const clickViewSwapVerified = isClickViewSwapVerified({
9212
9502
  resolvedAction,
9213
- isFinalStep,
9214
9503
  submitStep,
9215
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9216
9504
  isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
9217
9505
  networkDelta: post.networkCount - pre.networkCount,
9218
9506
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
@@ -9330,10 +9618,49 @@ async function executeStepWithHealing(params) {
9330
9618
  }
9331
9619
  }
9332
9620
  }
9621
+ // Phantom-click verdict, computed from the same pre/post pair the
9622
+ // signals above were derived from. Read HERE — at the point the retry
9623
+ // loop decides whether to keep going — rather than only after the
9624
+ // `verified` gate has already failed the attempt: an `"effective"`
9625
+ // verdict (a real, observable change) must end the attempt loop
9626
+ // immediately regardless of step shape (final, submit, or ordinary
9627
+ // interior toggle), the same as the narrower `verified` boolean does.
9628
+ // Previously the verdict was computed only for post-failure diagnostics/
9629
+ // escalation, so an attempt explicitly classified `"effective"` could
9630
+ // still be followed by wasted further attempts (see
9631
+ // recon-browser-1.12.54-phantom-click-verdict-inconsistent-blocks-terminal-tab-toggle-steps-from-ever-completing.md).
9632
+ record.phantomClickVerdict = (0, phantom_click_1.classifyPhantomClick)({
9633
+ actResultSuccess: record.actResultSuccess,
9634
+ pre,
9635
+ post,
9636
+ elementStateChanged: domVerified,
9637
+ isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
9638
+ });
9639
+ // An `"effective"` verdict driven purely by the page-wide byte-delta
9640
+ // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
9641
+ // here: `clickViewSwapVerified`/`formValueVerified` already own that
9642
+ // signal with the correct, much higher view-swap thresholds (5000B, or
9643
+ // 500B+textChanged for a reveal) — see
9644
+ // flow-runner.client-side-view-swap-cascade.test.ts's <5KB fixture,
9645
+ // which must stay unverified so the cascade keeps excluding candidates.
9646
+ // The one verdict-driven signal genuinely missing from `verified` is the
9647
+ // element-scoped selection-state change (`domVerified`): a same-page
9648
+ // toggle whose own committed state flips (Base Web `kind`/class, ARIA,
9649
+ // native `checked`) with no network/URL/advance-pattern match had NO
9650
+ // credit path at all outside the n+16 fallback's own checkbox-specific
9651
+ // check, even though `classifyPhantomClick` already classifies it
9652
+ // `"effective"` (excluded only on submit-shaped steps, to keep a stray
9653
+ // self-toggle from defeating the submit escalation). Gated through
9654
+ // `domVerifiedForStep` (not raw `domVerified`) so this credit path
9655
+ // respects the SAME DOM-only-advance veto as the block above: a wizard
9656
+ // "Next" step on a pattern-configured site whose only signal is a field
9657
+ // toggle must stay unverified, not get waved through by the verdict.
9658
+ const domEffectiveVerdict = !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)) && domVerifiedForStep;
9333
9659
  let verified = networkIsRealAdvance ||
9334
9660
  urlChanged ||
9335
9661
  domVerifiedForStep ||
9336
9662
  datepickerCommitted ||
9663
+ domEffectiveVerdict ||
9337
9664
  (!datepickerRejected &&
9338
9665
  !promptSelectorRejected &&
9339
9666
  (clickViewSwapVerified || formValueVerified));
@@ -9351,7 +9678,20 @@ async function executeStepWithHealing(params) {
9351
9678
  // state. A 2xx network response alone is insufficient (could be
9352
9679
  // telemetry). The Haiku judge defaults to verified=false when
9353
9680
  // ambiguous — strong evidence, not lax permission.
9354
- if (verified && requireSubmitEndpoint) {
9681
+ //
9682
+ // Only re-litigate an already-`verified` step through the judge when
9683
+ // the step is EXPLICITLY the submit (`submitStep: true`) or a genuine
9684
+ // transition fired (`networkIsRealAdvance` / `urlChanged`). A step that
9685
+ // reached `verified` purely via the element-scoped selection-state
9686
+ // credit (`domVerifiedForStep`) with no explicit `submitStep` flag is
9687
+ // the inferred-final-step case — a same-page toggle the recon slice
9688
+ // happened to end on, not the flow's real submit action (see
9689
+ // flow-runner.final-step-toggle-not-submit-shaped-acceptance.test.ts).
9690
+ // Forcing that toggle through the submit judge (which requires a DOM/
9691
+ // URL/title post-submit signal it will never produce) false-negatives
9692
+ // an already-genuine credit.
9693
+ const hasSubmitTransitionSignal = submitStep || networkIsRealAdvance || urlChanged;
9694
+ if (verified && requireSubmitEndpoint && hasSubmitTransitionSignal) {
9355
9695
  // Cap the scan from preMetaLength so we don't accept a historical
9356
9696
  // submit-shaped capture from an earlier step as proof for this one.
9357
9697
  const tail = recentCaptureMeta.slice(preMetaLength);
@@ -9698,6 +10038,36 @@ async function executeStepWithHealing(params) {
9698
10038
  const weakDomSignalsAllowed = ((!isFinalStep && !submitStep) || requireSubmitEndpoint) &&
9699
10039
  !isCheckboxOrRadioIntentStep(step) &&
9700
10040
  !clickTargetIsSelectionMarker;
10041
+ // Same effective-verdict credit the primary attempt's completion
10042
+ // gate uses (see the `record.phantomClickVerdict` computation
10043
+ // above), re-run against THIS fallback click's own pre/post pair
10044
+ // (`pre`/`retryPost`) rather than the stale pre/post the primary
10045
+ // technique captured — the primary technique's own act() call may
10046
+ // not have produced any DOM effect at all, with the fallback's
10047
+ // `el.click()` being the click that actually landed. Still subject
10048
+ // to the `clickBlockedByDisabled`/`clickBlockedByInvalid`/
10049
+ // `fallbackDomOnlyAdvance` vetoes below, same as every other
10050
+ // fallback signal. `classifyPhantomClick` can still classify a
10051
+ // submit-shaped step "effective" purely off the page-wide
10052
+ // `TRIVIAL_DOM_DELTA_BYTES` floor (elementStateChanged is already
10053
+ // excluded on submit-shaped steps below via `isSubmitShapedStep`),
10054
+ // so the byte-delta-only branch of that verdict must not bypass
10055
+ // the submit-endpoint corroboration gate any more than the raw
10056
+ // `retryHtmlDelta`/`retryTextChanged` signals below may — same
10057
+ // `retrySubmitShaped` exclusion the primary gate's
10058
+ // `domEffectiveVerdict` applies. See
10059
+ // flow-runner.viewswap-blocked-submit-acceptance.test.ts (must stay
10060
+ // gated) and flow-runner.pricing-tab-symmetric-swap-verdict-
10061
+ // acceptance.test.ts (a non-submit-shaped final step must still get
10062
+ // credit here).
10063
+ const retrySubmitShaped = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag);
10064
+ const retryVerdict = (0, phantom_click_1.classifyPhantomClick)({
10065
+ actResultSuccess: record.actResultSuccess,
10066
+ pre,
10067
+ post: retryPost,
10068
+ elementStateChanged: retrySelectionStateChanged,
10069
+ isSubmitShapedStep: retrySubmitShaped,
10070
+ });
9701
10071
  let retryVerified = !clickBlockedByDisabled &&
9702
10072
  !clickBlockedByInvalid &&
9703
10073
  !fallbackDomOnlyAdvance &&
@@ -9705,14 +10075,25 @@ async function executeStepWithHealing(params) {
9705
10075
  retryUrlChanged ||
9706
10076
  checkboxStateVerified ||
9707
10077
  retrySelectionStateChanged ||
10078
+ (!retrySubmitShaped && retryVerdict === "effective") ||
9708
10079
  (weakDomSignalsAllowed &&
9709
10080
  (retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
10081
+ if (retryVerified) {
10082
+ record.phantomClickVerdict = retryVerdict;
10083
+ }
9710
10084
  // Apply the same submit-endpoint gate the primary verifier uses.
9711
10085
  // Without this, the n+16 fallback would still ride past a
9712
10086
  // tracking-pixel-only click on the final step. Same Haiku LLM
9713
10087
  // judgment as the primary verifier — multi-signal corroboration
9714
- // replaces deterministic URL regex matching.
9715
- if (retryVerified && requireSubmitEndpoint) {
10088
+ // replaces deterministic URL regex matching. Same
10089
+ // hasSubmitTransitionSignal carve-out as the primary verifier: an
10090
+ // inferred (non-explicit-submitStep) final step whose n+16 credit
10091
+ // came only from the element-scoped `retrySelectionStateChanged`
10092
+ // signal is the same ordinary-toggle shape, not a real submit —
10093
+ // don't force it through a judge that requires evidence it can
10094
+ // never produce.
10095
+ const retryHasSubmitTransitionSignal = submitStep || retryNetworkIsRealAdvance || retryUrlChanged;
10096
+ if (retryVerified && requireSubmitEndpoint && retryHasSubmitTransitionSignal) {
9716
10097
  const tail = recentCaptureMeta.slice(preMetaLength);
9717
10098
  // DOM-state probe (deterministic).
9718
10099
  let domSubmittedMatch = null;
@@ -9820,22 +10201,10 @@ async function executeStepWithHealing(params) {
9820
10201
  return "completed";
9821
10202
  }
9822
10203
  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
- });
10204
+ // record.phantomClickVerdict was already computed above, before the
10205
+ // `verified` gate — reaching this point means it is NOT `"effective"`
10206
+ // (an effective verdict would have short-circuited to "completed"
10207
+ // above), so it is either `"phantom"` or `"unresolved"` here.
9839
10208
  const reason = record.errorMessage
9840
10209
  ? effectSignals
9841
10210
  ? `${record.errorMessage}; ${effectSignals}`