@enricai/barnacle 1.12.81 → 1.13.0

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 (69) hide show
  1. package/dist/lib/ats-field-vocabulary.d.ts.map +1 -1
  2. package/dist/lib/ats-field-vocabulary.js +86 -3
  3. package/dist/lib/ats-field-vocabulary.js.map +1 -1
  4. package/dist/lib/llm/judges/verify-step-advanced.d.ts +1 -1
  5. package/dist/lib/llm/judges/verify-step-advanced.d.ts.map +1 -1
  6. package/dist/lib/llm/judges/verify-step-advanced.js +5 -3
  7. package/dist/lib/llm/judges/verify-step-advanced.js.map +1 -1
  8. package/dist/lib/llm/judges/verify-submit.d.ts +2 -2
  9. package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -1
  10. package/dist/lib/llm/judges/verify-submit.js +17 -5
  11. package/dist/lib/llm/judges/verify-submit.js.map +1 -1
  12. package/dist/lib/random.d.ts +7 -3
  13. package/dist/lib/random.d.ts.map +1 -1
  14. package/dist/lib/random.js +27 -4
  15. package/dist/lib/random.js.map +1 -1
  16. package/dist/plugins/config-plugin.d.ts +1 -0
  17. package/dist/plugins/config-plugin.d.ts.map +1 -1
  18. package/dist/plugins/config-plugin.js +7 -1
  19. package/dist/plugins/config-plugin.js.map +1 -1
  20. package/dist/plugins/loader.d.ts +2 -2
  21. package/dist/plugins/loader.d.ts.map +1 -1
  22. package/dist/plugins/loader.js +6 -3
  23. package/dist/plugins/loader.js.map +1 -1
  24. package/dist/scraper/click-settle.d.ts +17 -2
  25. package/dist/scraper/click-settle.d.ts.map +1 -1
  26. package/dist/scraper/click-settle.js +36 -30
  27. package/dist/scraper/click-settle.js.map +1 -1
  28. package/dist/scraper/deep-locator-actuate.d.ts.map +1 -1
  29. package/dist/scraper/deep-locator-actuate.js +17 -1
  30. package/dist/scraper/deep-locator-actuate.js.map +1 -1
  31. package/dist/scraper/deep-locator-fake.js +1 -1
  32. package/dist/scraper/deep-locator-fake.js.map +1 -1
  33. package/dist/scraper/errors.d.ts +23 -2
  34. package/dist/scraper/errors.d.ts.map +1 -1
  35. package/dist/scraper/errors.js +32 -4
  36. package/dist/scraper/errors.js.map +1 -1
  37. package/dist/scraper/flow-runner.d.ts +176 -36
  38. package/dist/scraper/flow-runner.d.ts.map +1 -1
  39. package/dist/scraper/flow-runner.js +778 -326
  40. package/dist/scraper/flow-runner.js.map +1 -1
  41. package/dist/scraper/http-client.d.ts +7 -2
  42. package/dist/scraper/http-client.d.ts.map +1 -1
  43. package/dist/scraper/http-client.js +132 -19
  44. package/dist/scraper/http-client.js.map +1 -1
  45. package/dist/scraper/metrics.d.ts +2 -2
  46. package/dist/scraper/metrics.js +2 -2
  47. package/dist/scraper/phantom-click.d.ts +51 -1
  48. package/dist/scraper/phantom-click.d.ts.map +1 -1
  49. package/dist/scraper/phantom-click.js +170 -19
  50. package/dist/scraper/phantom-click.js.map +1 -1
  51. package/dist/scraper/runtime-capture-store.d.ts +38 -0
  52. package/dist/scraper/runtime-capture-store.d.ts.map +1 -0
  53. package/dist/scraper/runtime-capture-store.js +103 -0
  54. package/dist/scraper/runtime-capture-store.js.map +1 -0
  55. package/dist/scraper/segmented-date-spinbutton.d.ts +13 -0
  56. package/dist/scraper/segmented-date-spinbutton.d.ts.map +1 -0
  57. package/dist/scraper/segmented-date-spinbutton.js +71 -0
  58. package/dist/scraper/segmented-date-spinbutton.js.map +1 -0
  59. package/dist/scripts/recon-browser.d.ts +0 -8
  60. package/dist/scripts/recon-browser.d.ts.map +1 -1
  61. package/dist/scripts/recon-browser.js +102 -50
  62. package/dist/scripts/recon-browser.js.map +1 -1
  63. package/dist/scripts/recon-generate.d.ts +11 -0
  64. package/dist/scripts/recon-generate.d.ts.map +1 -1
  65. package/dist/scripts/recon-generate.js +180 -53
  66. package/dist/scripts/recon-generate.js.map +1 -1
  67. package/dist/site-plugin.d.ts +7 -4
  68. package/dist/site-plugin.d.ts.map +1 -1
  69. package/package.json +1 -1
@@ -34,6 +34,7 @@ exports.classifyStepSubmitIntent = classifyStepSubmitIntent;
34
34
  exports.isStepDeclaredNonSubmit = isStepDeclaredNonSubmit;
35
35
  exports.isStepSubmitShaped = isStepSubmitShaped;
36
36
  exports.isStepSettleWaitShaped = isStepSettleWaitShaped;
37
+ exports.isGenuineFinalSubmit = isGenuineFinalSubmit;
37
38
  exports.isMidFlowSubmitShapedStep = isMidFlowSubmitShapedStep;
38
39
  exports.isCheckboxOrRadioIntentStep = isCheckboxOrRadioIntentStep;
39
40
  exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
@@ -47,6 +48,7 @@ exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
47
48
  exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
48
49
  exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
49
50
  exports.isNullVerdictAdvanceCredited = isNullVerdictAdvanceCredited;
51
+ exports.isMidFlowAdvanceCredited = isMidFlowAdvanceCredited;
50
52
  exports.isClickViewSwapVerified = isClickViewSwapVerified;
51
53
  exports.selectionCountFromSignature = selectionCountFromSignature;
52
54
  exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
@@ -74,10 +76,12 @@ exports.extractGaEventEvidence = extractGaEventEvidence;
74
76
  exports.normalizeDateValue = normalizeDateValue;
75
77
  exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
76
78
  exports.fillTextDatepickerInput = fillTextDatepickerInput;
79
+ exports.isDateSegmentAtSelector = isDateSegmentAtSelector;
77
80
  exports.verifyFillReadback = verifyFillReadback;
78
81
  exports.verifyPromptSelectorCommitted = verifyPromptSelectorCommitted;
79
82
  exports.tailRetargetPhrasesForStep = tailRetargetPhrasesForStep;
80
83
  exports.stepQuotesValueNotControlName = stepQuotesValueNotControlName;
84
+ exports.waitForUploadNetworkSignal = waitForUploadNetworkSignal;
81
85
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
82
86
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
83
87
  exports.attachToSurfacedInput = attachToSurfacedInput;
@@ -91,12 +95,15 @@ exports.parseSegmentedDateValue = parseSegmentedDateValue;
91
95
  exports.resolveSegmentedDateGroup = resolveSegmentedDateGroup;
92
96
  exports.parseSegmentedPartStep = parseSegmentedPartStep;
93
97
  exports.trySegmentedDateSpinbuttonPrimitive = trySegmentedDateSpinbuttonPrimitive;
98
+ exports.runSegmentedDateSpinbuttonPrimitive = runSegmentedDateSpinbuttonPrimitive;
94
99
  exports.parseRadioStep = parseRadioStep;
95
100
  exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
96
101
  exports.parseCheckStep = parseCheckStep;
97
102
  exports.parseCheckStateStep = parseCheckStateStep;
98
103
  exports.pollEnumerate = pollEnumerate;
99
104
  exports.waitForTransitionBody = waitForTransitionBody;
105
+ exports.settleAndSnapshotPostClick = settleAndSnapshotPostClick;
106
+ exports.readMidFlowPostClickAdvance = readMidFlowPostClickAdvance;
100
107
  exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
101
108
  exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
102
109
  exports.trySelectPrimitive = trySelectPrimitive;
@@ -157,6 +164,8 @@ const errors_2 = require("./errors");
157
164
  const frame_target_1 = require("./frame-target");
158
165
  const phantom_click_1 = require("./phantom-click");
159
166
  const quoted_spans_1 = require("./quoted-spans");
167
+ const runtime_capture_store_1 = require("./runtime-capture-store");
168
+ const segmented_date_spinbutton_1 = require("./segmented-date-spinbutton");
160
169
  const session_teardown_1 = require("./session-teardown");
161
170
  const spa_readiness_1 = require("./spa-readiness");
162
171
  const stagehand_guard_1 = require("./stagehand-guard");
@@ -165,7 +174,6 @@ const target_resolution_diagnostic_1 = require("./target-resolution-diagnostic")
165
174
  const trusted_click_1 = require("./trusted-click");
166
175
  const upload_affordance_expr_1 = require("./upload-affordance-expr");
167
176
  const watchdog_1 = require("./watchdog");
168
- const recon_shared_1 = require("../scripts/recon-shared");
169
177
  const client_1 = require("../testmail/client");
170
178
  var spa_readiness_2 = require("./spa-readiness");
171
179
  Object.defineProperty(exports, "waitForSpaReady", { enumerable: true, get: function () { return spa_readiness_2.waitForSpaReady; } });
@@ -393,6 +401,8 @@ function wireSignalCapture(page, params) {
393
401
  method: capture.method,
394
402
  status: capture.status,
395
403
  url: capture.url,
404
+ hasBody: (req.requestPostData ?? "").length > 0,
405
+ multipart: /^multipart\//i.test(requestContentType(req.requestHeaders)),
396
406
  });
397
407
  if (recentCaptureMeta.length > RECENT_CAPTURES_WINDOW) {
398
408
  recentCaptureMeta.splice(0, recentCaptureMeta.length - RECENT_CAPTURES_WINDOW);
@@ -1106,7 +1116,9 @@ function prepareFailureDumpBody(raw) {
1106
1116
  function ariaCheckedReadExpr(elVar) {
1107
1117
  return `${elVar}.getAttribute("aria-checked")`;
1108
1118
  }
1109
- const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "", values: "", state: "" }; const t = b.innerText || ""; const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null); const values = controls.map((el) => { if (el.type === "checkbox" || el.type === "radio") return el.checked ? "1" : "0"; return el.value || ""; }).join("|").slice(0, 2000); const ariaSel = "[aria-pressed],[aria-checked],[aria-selected],[data-state],[data-selected],[data-checked],[role=option],[role=switch],[role=checkbox],[role=menuitemcheckbox]"; const classSel = ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)}; const classRx = ${browser_click_expr_1.SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC}; const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null; const seen = new Set(); for (const el of b.querySelectorAll(ariaSel + "," + classSel)) { if (el.offsetParent !== null && !skip(el)) seen.add(el); } let idx = 0; const parts = []; for (const el of seen) { const ap = el.getAttribute("aria-pressed") || ""; const ac = el.getAttribute("aria-checked") || ""; const as = el.getAttribute("aria-selected") || ""; const dsRaw = el.getAttribute("data-state") || ""; const ds = (dsRaw === "open" || dsRaw === "closed") ? "" : dsRaw; const dsel = el.hasAttribute("data-selected") ? "1" : ""; const dchk = el.hasAttribute("data-checked") ? "1" : ""; const clsHit = classRx.test(el.getAttribute("class") || "") ? "1" : "0"; const i = idx++; if (!ap && !ac && !as && !ds && !dsel && !dchk && clsHit === "0") continue; parts.push(i + ":" + ap + "," + ac + "," + as + "," + ds + "," + dsel + "," + dchk + "," + clsHit); } const joined = parts.join("|"); let h = 2166136261; for (let k = 0; k < joined.length; k++) { h ^= joined.charCodeAt(k); h = Math.imul(h, 16777619); } const state = (h >>> 0).toString(36) + ":" + parts.length; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200), values, state }; })()`;
1119
+ /** Bound on {@link StepSnapshot.visibleText}, keeping a pathological page from bloating snapshots. */
1120
+ const VISIBLE_TEXT_EXCERPT_CHARS = 3000;
1121
+ const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "", values: "", state: "", full: "", digest: "" }; const t = b.innerText || ""; const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null); const values = controls.map((el) => { if (el.type === "checkbox" || el.type === "radio") return el.checked ? "1" : "0"; return el.value || ""; }).join("|").slice(0, 2000); const ariaSel = "[aria-pressed],[aria-checked],[aria-selected],[data-state],[data-selected],[data-checked],[role=option],[role=switch],[role=checkbox],[role=menuitemcheckbox]"; const classSel = ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)}; const classRx = ${browser_click_expr_1.SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC}; const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null; const seen = new Set(); for (const el of b.querySelectorAll(ariaSel + "," + classSel)) { if (el.offsetParent !== null && !skip(el)) seen.add(el); } let idx = 0; const parts = []; for (const el of seen) { const ap = el.getAttribute("aria-pressed") || ""; const ac = el.getAttribute("aria-checked") || ""; const as = el.getAttribute("aria-selected") || ""; const dsRaw = el.getAttribute("data-state") || ""; const ds = (dsRaw === "open" || dsRaw === "closed") ? "" : dsRaw; const dsel = el.hasAttribute("data-selected") ? "1" : ""; const dchk = el.hasAttribute("data-checked") ? "1" : ""; const clsHit = classRx.test(el.getAttribute("class") || "") ? "1" : "0"; const i = idx++; if (!ap && !ac && !as && !ds && !dsel && !dchk && clsHit === "0") continue; parts.push(i + ":" + ap + "," + ac + "," + as + "," + ds + "," + dsel + "," + dchk + "," + clsHit); } const joined = parts.join("|"); let h = 2166136261; for (let k = 0; k < joined.length; k++) { h ^= joined.charCodeAt(k); h = Math.imul(h, 16777619); } const state = (h >>> 0).toString(36) + ":" + parts.length; const norm = t.replace(/\\s+/g, " ").trim(); let th = 2166136261; for (let tk = 0; tk < norm.length; tk++) { th ^= norm.charCodeAt(tk); th = Math.imul(th, 16777619); } return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200), values, state, full: norm.slice(0, ${VISIBLE_TEXT_EXCERPT_CHARS}), digest: (th >>> 0).toString(36) + ":" + norm.length }; })()`;
1110
1122
  /** Caps mirroring {@link DOM_SNAPSHOT_EXPR}'s truncation so a pathological page can't bloat the failure bundle. */
1111
1123
  const FIELD_VALUES_AT_FAILURE_MAX_CONTROLS = 200;
1112
1124
  const FIELD_VALUES_AT_FAILURE_MAX_VALUE_LENGTH = 500;
@@ -1156,13 +1168,49 @@ function fieldValuesAtFailureExpr() {
1156
1168
  return out;
1157
1169
  })()`;
1158
1170
  }
1171
+ /**
1172
+ * Reads the in-page quiescence probe as a typed, non-null value. A detached
1173
+ * frame or malformed probe result reads as idle so settle consumes the reader
1174
+ * directly and needs no guard of its own.
1175
+ */
1176
+ async function readPageQuiescence(target) {
1177
+ const idle = { pending: 0, mutations: 0 };
1178
+ const result = await Promise.resolve(target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR)).catch(() => idle);
1179
+ if (result === null || typeof result !== "object")
1180
+ return idle;
1181
+ const { pending, mutations } = result;
1182
+ return typeof pending === "number" && typeof mutations === "number"
1183
+ ? { pending, mutations }
1184
+ : idle;
1185
+ }
1186
+ /**
1187
+ * Installs the quiescence probe before a click, because it only starts counting
1188
+ * requests and mutations from its first read and a post-click install would miss
1189
+ * everything the click triggers immediately.
1190
+ */
1191
+ async function armPageQuiescence(target) {
1192
+ await readPageQuiescence(target);
1193
+ }
1194
+ /**
1195
+ * Reads only the visible-text key settle polls on. Going through
1196
+ * {@link snapshotPage} also read the frame URL, which rejects once a submit
1197
+ * detaches the OOPIF and would abort the settle mid-poll; a failed read here
1198
+ * degrades to the empty signature like the snapshot's own text read does.
1199
+ */
1200
+ async function readSettleTextSignature(target) {
1201
+ const result = await Promise.resolve(target.evaluate(DOM_SNAPSHOT_EXPR)).catch(() => null);
1202
+ if (result === null || typeof result !== "object")
1203
+ return "";
1204
+ const { text, digest } = result;
1205
+ return (0, click_settle_1.settleTextKey)(typeof text === "string" ? text : "", typeof digest === "string" ? digest : undefined);
1206
+ }
1159
1207
  /**
1160
1208
  * Settles after a click using the target's own text signature and in-page
1161
1209
  * quiescence reader, so every post-click site decides from the same settle
1162
1210
  * signal instead of hand-building the readers.
1163
1211
  */
1164
- async function settleWithPageQuiescence(page, target, signalCounter, preNetworkCount, submitShaped, preVisibleTextSignature) {
1165
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, preNetworkCount, submitShaped, async () => (await snapshotPage(target, signalCounter)).visibleTextSignature, preVisibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
1212
+ async function settleWithPageQuiescence(page, target, signalCounter, preNetworkCount, submitShaped, preVisibleTextSignature, preVisibleTextDigest) {
1213
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, preNetworkCount, submitShaped, () => readSettleTextSignature(target), (0, click_settle_1.settleTextKey)(preVisibleTextSignature, preVisibleTextDigest), () => readPageQuiescence(target));
1166
1214
  }
1167
1215
  /**
1168
1216
  * Captures the pre/post signal triple the submit-verify cascade diffs.
@@ -1181,6 +1229,8 @@ async function settleWithPageQuiescence(page, target, signalCounter, preNetworkC
1181
1229
  async function snapshotPage(target, signalCounter, page, captureSelectionState = false) {
1182
1230
  let bodyHtmlLength = 0;
1183
1231
  let visibleTextSignature = "";
1232
+ let visibleText = "";
1233
+ let visibleTextDigest = "";
1184
1234
  let formValueSignature = "";
1185
1235
  let selectionStateSignature = "";
1186
1236
  try {
@@ -1193,6 +1243,10 @@ async function snapshotPage(target, signalCounter, page, captureSelectionState =
1193
1243
  typeof result.text === "string") {
1194
1244
  bodyHtmlLength = result.html;
1195
1245
  visibleTextSignature = result.text;
1246
+ const rawFull = result.full;
1247
+ visibleText = typeof rawFull === "string" ? rawFull : "";
1248
+ const rawDigest = result.digest;
1249
+ visibleTextDigest = typeof rawDigest === "string" ? rawDigest : "";
1196
1250
  const rawValues = result.values;
1197
1251
  formValueSignature = typeof rawValues === "string" ? rawValues : "";
1198
1252
  const rawState = result.state;
@@ -1235,6 +1289,8 @@ async function snapshotPage(target, signalCounter, page, captureSelectionState =
1235
1289
  url,
1236
1290
  bodyHtmlLength,
1237
1291
  visibleTextSignature,
1292
+ visibleText,
1293
+ visibleTextDigest: visibleTextDigest === "" ? undefined : visibleTextDigest,
1238
1294
  formValueSignature,
1239
1295
  selectionStateSignature,
1240
1296
  selectionStateByXpath,
@@ -1534,12 +1590,20 @@ function isStepSettleWaitShaped(params) {
1534
1590
  return isStepSubmitShaped(params) || isAdvanceStep(params.step);
1535
1591
  }
1536
1592
  /**
1537
- * Submit-shaped but NOT the flow's final step: a mid-flow advancing submit
1593
+ * The single definition of a genuine final submit: the last slot of a flow
1594
+ * that has submit semantics. A final slot without them is mid-flow.
1595
+ */
1596
+ function isGenuineFinalSubmit(params) {
1597
+ return params.isFinalStep && params.flowHasSubmitSemantics;
1598
+ }
1599
+ /**
1600
+ * Submit-shaped but NOT the flow's genuine final submit (a final slot without
1601
+ * submit semantics, e.g. after a replan trims the tail, is still mid-flow): a mid-flow advancing submit
1538
1602
  * (e.g. account creation) that can be credited on a demonstrable page advance
1539
1603
  * instead of needing the final submit's network/URL proof.
1540
1604
  */
1541
1605
  function isMidFlowSubmitShapedStep(params) {
1542
- if (params.isFinalStep)
1606
+ if (isGenuineFinalSubmit(params))
1543
1607
  return false;
1544
1608
  return isStepSubmitShaped(params) || params.elementSubmitShaped === true;
1545
1609
  }
@@ -1669,15 +1733,7 @@ function latestCaptureIndex(recentCaptures) {
1669
1733
  * so callers scan in capture order. Returns [] when the dir is unreadable.
1670
1734
  */
1671
1735
  function capturesAfterIndex(preIdx, capturesDir) {
1672
- let entries;
1673
- try {
1674
- entries = (0, node_fs_1.readdirSync)(capturesDir);
1675
- }
1676
- catch {
1677
- return [];
1678
- }
1679
- return entries
1680
- .filter((f) => f.endsWith(".json") && !f.endsWith(".decoded.json"))
1736
+ return (0, runtime_capture_store_1.listCaptureFiles)(capturesDir)
1681
1737
  .map((f) => ({ f, idx: parseCaptureIndex(f) }))
1682
1738
  .filter((e) => e.idx !== null && e.idx > preIdx)
1683
1739
  .sort((a, b) => a.idx - b.idx)
@@ -1708,7 +1764,7 @@ function windowHasTransitionBody(params) {
1708
1764
  const { preIdx, advanceTransitionBodyPattern } = params;
1709
1765
  if (!advanceTransitionBodyPattern)
1710
1766
  return false;
1711
- const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
1767
+ const capturesDir = params.capturesDir ?? (0, runtime_capture_store_1.defaultCapturesDir)();
1712
1768
  let rx;
1713
1769
  try {
1714
1770
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -1718,8 +1774,9 @@ function windowHasTransitionBody(params) {
1718
1774
  }
1719
1775
  for (const filename of capturesAfterIndex(preIdx, capturesDir)) {
1720
1776
  try {
1721
- const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, filename), "utf8");
1722
- const capture = JSON.parse(raw);
1777
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
1778
+ if (!capture)
1779
+ continue;
1723
1780
  const body = typeof capture.requestPostData === "string"
1724
1781
  ? capture.requestPostData
1725
1782
  : JSON.stringify(capture.requestPostData ?? "");
@@ -1750,7 +1807,7 @@ function windowHasAdvanceTransition(params) {
1750
1807
  const { preIdx, advanceTransitionBodyPattern } = params;
1751
1808
  if (!advanceTransitionBodyPattern)
1752
1809
  return false;
1753
- const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
1810
+ const capturesDir = params.capturesDir ?? (0, runtime_capture_store_1.defaultCapturesDir)();
1754
1811
  let rx;
1755
1812
  try {
1756
1813
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -1760,8 +1817,9 @@ function windowHasAdvanceTransition(params) {
1760
1817
  }
1761
1818
  for (const filename of capturesAfterIndex(preIdx, capturesDir)) {
1762
1819
  try {
1763
- const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, filename), "utf8");
1764
- const capture = JSON.parse(raw);
1820
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
1821
+ if (!capture)
1822
+ continue;
1765
1823
  const body = typeof capture.requestPostData === "string"
1766
1824
  ? capture.requestPostData
1767
1825
  : JSON.stringify(capture.requestPostData ?? "");
@@ -1831,13 +1889,29 @@ function isDomOnlyAdvanceVerified(params) {
1831
1889
  * rejection, so strong structural click signals (a real network advance or a
1832
1890
  * demonstrable page advance) credit the step instead of failing it and
1833
1891
  * re-clicking at the same position. Final steps and weak-signal cases return
1834
- * false and keep the DOM-state fallback / failure path.
1892
+ * false and keep the DOM-state fallback / failure path. Invalid markers
1893
+ * describe the page the click landed on, so they only veto a network-only
1894
+ * credit, never a settled page advance.
1835
1895
  */
1836
1896
  function isNullVerdictAdvanceCredited(params) {
1837
- if (!params.midFlowSubmit || !params.judgeUnavailable || params.invalidMarkerCount > 0) {
1897
+ if (!params.midFlowSubmit || !params.judgeUnavailable)
1838
1898
  return false;
1839
- }
1840
- return params.networkAdvanced || params.pageAdvanced;
1899
+ if (params.pageAdvanced)
1900
+ return true;
1901
+ return params.networkAdvanced && params.invalidMarkerCount <= 0;
1902
+ }
1903
+ /**
1904
+ * Single decision both post-click judge sites use to credit a mid-flow submit
1905
+ * on the page's own demonstrable advance, so a stale title or missing network
1906
+ * the judge reads with final-submit eyes cannot undo it. `signalAdvanced` is the
1907
+ * site's own settled-advance signal (the primary click's view-swap credit, the
1908
+ * fallback click's effective verdict); the null-verdict path covers an
1909
+ * unavailable judge.
1910
+ */
1911
+ function isMidFlowAdvanceCredited(params) {
1912
+ if (params.midFlowSubmit && params.signalAdvanced)
1913
+ return true;
1914
+ return isNullVerdictAdvanceCredited(params);
1841
1915
  }
1842
1916
  /**
1843
1917
  * Client-side view-swap verification gate. Credits a click that produces
@@ -1930,7 +2004,7 @@ function isNullVerdictAdvanceCredited(params) {
1930
2004
  *
1931
2005
  * **Mid-flow submit:** `midFlowSubmit` (see {@link isMidFlowSubmitShapedStep})
1932
2006
  * lets a submit-shaped, non-final step be credited when `pageAdvanced` (the
1933
- * shared {@link hasPageAdvanced} judgement of the visible text; defaults to
2007
+ * shared {@link hasSettledPageAdvanced} judgement of the visible text; defaults to
1934
2008
  * `textChanged` when omitted) holds — byte delta is not required, since a
1935
2009
  * wizard swap can keep the HTML size — with the same invalid-marker and
1936
2010
  * destination vetoes. The clicked control being gone is NOT a veto here: a
@@ -1953,7 +2027,6 @@ function isClickViewSwapVerified(params) {
1953
2027
  midFlowActRan &&
1954
2028
  destinationPlausible !== false &&
1955
2029
  !isAdvanceWithPattern &&
1956
- invalidMarkerDelta <= 0 &&
1957
2030
  textChanged &&
1958
2031
  pageAdvanced) {
1959
2032
  return true;
@@ -2071,7 +2144,7 @@ async function countNgInvalidContainers(target) {
2071
2144
  function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLength) {
2072
2145
  const bytesDelta = post.bodyHtmlLength - pre.bodyHtmlLength;
2073
2146
  const networkDelta = post.networkCount - pre.networkCount;
2074
- const textChanged = post.visibleTextSignature !== pre.visibleTextSignature;
2147
+ const textChanged = (0, phantom_click_1.hasVisibleTextChanged)(pre, post);
2075
2148
  const valueChanged = post.formValueSignature !== pre.formValueSignature;
2076
2149
  const selectionStateChanged = post.selectionStateSignature !== pre.selectionStateSignature;
2077
2150
  const windowCaptures = recentCaptureMeta.slice(preMetaLength);
@@ -2212,9 +2285,11 @@ function shouldSkipTechnique(params) {
2212
2285
  * navigations, partial DOM reflows, etc.).
2213
2286
  */
2214
2287
  function isSubmitRevealedInvalid(params) {
2215
- const { isFinalStep, requireSubmitEndpoint, resolvedMethod, effectSignals, preSubmitInvalidCount, postAttemptInvalidCount, } = params;
2288
+ const { isFinalStep, requireSubmitEndpoint, resolvedMethod, effectSignals, preSubmitInvalidCount, postAttemptInvalidCount, midFlowAdvanced = false, } = params;
2216
2289
  if (!isFinalStep)
2217
2290
  return false;
2291
+ if (midFlowAdvanced)
2292
+ return false;
2218
2293
  if (!requireSubmitEndpoint)
2219
2294
  return false;
2220
2295
  if (resolvedMethod !== "click")
@@ -2905,24 +2980,36 @@ const STEP_REJUDGE_COMPARE_CHARS = 1200;
2905
2980
  */
2906
2981
  async function judgeStepOutcome(params) {
2907
2982
  const { client, captureFn, step, isFinalStep, target } = params;
2908
- if (isFinalStep) {
2983
+ const genuineFinal = isGenuineFinalSubmit({
2984
+ isFinalStep,
2985
+ flowHasSubmitSemantics: params.flowHasSubmitSemantics,
2986
+ });
2987
+ if (genuineFinal) {
2909
2988
  return (0, verify_submit_1.verifySubmitWithLLM)({
2910
2989
  client,
2911
2990
  input: {
2912
- ...(await buildSubmitJudgeStepContext({ step, isFinalStep, target })),
2991
+ ...(await buildSubmitJudgeStepContext({ step, isFinalStep: genuineFinal, target })),
2913
2992
  ...params.submitInput,
2914
2993
  },
2915
2994
  captureFn,
2916
2995
  });
2917
2996
  }
2918
- await (0, click_settle_1.settleAfterClick)(params.page, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({ step, submitStep: false, isFinalStep, flowHasSubmitSemantics: false }), async () => {
2919
- const text = await probeRawVisibleText(target);
2920
- return `${text.length}:${text.slice(0, 200)}`;
2921
- }, params.preVisibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
2922
- const colon = params.preVisibleTextSignature.indexOf(":");
2923
- const beforeText = (colon === -1 ? params.preVisibleTextSignature : params.preVisibleTextSignature.slice(colon + 1))
2997
+ const hasSettledRead = params.settledPostVisibleText !== undefined;
2998
+ if (!hasSettledRead) {
2999
+ await settleWithPageQuiescence(params.page, target, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({
3000
+ step,
3001
+ submitStep: params.submitStep,
3002
+ isFinalStep,
3003
+ flowHasSubmitSemantics: params.flowHasSubmitSemantics,
3004
+ }) || params.elementSubmitShaped === true, params.preVisibleTextSignature, params.preVisibleTextDigest);
3005
+ }
3006
+ const signatureColon = params.preVisibleTextSignature.indexOf(":");
3007
+ const signatureHead = (signatureColon === -1
3008
+ ? params.preVisibleTextSignature
3009
+ : params.preVisibleTextSignature.slice(signatureColon + 1))
2924
3010
  .replace(/\s+/g, " ")
2925
3011
  .trim();
3012
+ const beforeText = params.preVisibleText ? params.preVisibleText : signatureHead;
2926
3013
  const judgeText = (afterText) => (0, verify_step_advanced_1.verifyStepAdvancedWithLLM)({
2927
3014
  client,
2928
3015
  input: {
@@ -2934,7 +3021,7 @@ async function judgeStepOutcome(params) {
2934
3021
  captureFn,
2935
3022
  });
2936
3023
  const rejudgeIfTextMoved = async (first, judgedText) => {
2937
- if (first === null || first.advanced)
3024
+ if (first === null || first.advanced || hasSettledRead)
2938
3025
  return first;
2939
3026
  await params.page.waitForTimeout(STEP_REJUDGE_DELAY_MS);
2940
3027
  const laterText = await probeVisibleTextForJudge(target);
@@ -2944,7 +3031,9 @@ async function judgeStepOutcome(params) {
2944
3031
  }
2945
3032
  return (await judgeText(laterText)) ?? first;
2946
3033
  };
2947
- const afterText = await probeVisibleTextForJudge(target);
3034
+ const afterText = hasSettledRead
3035
+ ? (params.settledPostVisibleText ?? "")
3036
+ : await probeVisibleTextForJudge(target);
2948
3037
  const firstVerdict = await judgeText(afterText);
2949
3038
  const verdict = await rejudgeIfTextMoved(firstVerdict, afterText);
2950
3039
  if (verdict === null)
@@ -3058,7 +3147,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
3058
3147
  * deterministic hostname equality. Empty list / "any-4xx" mode disables
3059
3148
  * the host filter and returns any 4xx in the window.
3060
3149
  */
3061
- ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
3150
+ ownBackendHostnames, capturesDir = (0, runtime_capture_store_1.defaultCapturesDir)(), mode = "strict") {
3062
3151
  if (recentCaptureFilenames.length === 0)
3063
3152
  return "";
3064
3153
  if (mode === "strict" && ownBackendHostnames.length === 0)
@@ -3070,9 +3159,9 @@ ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().grap
3070
3159
  continue;
3071
3160
  seen.add(filename);
3072
3161
  try {
3073
- const path = (0, node_path_1.join)(capturesDir, filename);
3074
- const raw = (0, node_fs_1.readFileSync)(path, "utf8");
3075
- const capture = JSON.parse(raw);
3162
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
3163
+ if (!capture)
3164
+ continue;
3076
3165
  if (typeof capture.url !== "string")
3077
3166
  continue;
3078
3167
  if (mode === "strict") {
@@ -3149,7 +3238,7 @@ ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().grap
3149
3238
  * Returns a numbered evidence list. Empty string when no GA collect
3150
3239
  * captures are present. Advisory — never load-bearing.
3151
3240
  */
3152
- function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
3241
+ function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, runtime_capture_store_1.defaultCapturesDir)()) {
3153
3242
  if (recentCaptureFilenames.length === 0)
3154
3243
  return "";
3155
3244
  const records = [];
@@ -3159,9 +3248,9 @@ function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_
3159
3248
  continue;
3160
3249
  seen.add(filename);
3161
3250
  try {
3162
- const path = (0, node_path_1.join)(capturesDir, filename);
3163
- const raw = (0, node_fs_1.readFileSync)(path, "utf8");
3164
- const capture = JSON.parse(raw);
3251
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
3252
+ if (!capture)
3253
+ continue;
3165
3254
  if (typeof capture.url !== "string")
3166
3255
  continue;
3167
3256
  let url;
@@ -3533,6 +3622,25 @@ async function fillTextDatepickerInput(target, selector, value) {
3533
3622
  return { filled: false, postValue: "", strategy: "none" };
3534
3623
  }
3535
3624
  }
3625
+ /**
3626
+ * Pre-write check for the observe-act fill override: a lone-segment write
3627
+ * (insertText) cannot compose a date, so callers refuse before any write.
3628
+ * An evaluate error propagates so the attempt fails rather than writing blind;
3629
+ * only a selector form with no XPath body (nothing to resolve) answers false.
3630
+ */
3631
+ async function isDateSegmentAtSelector(target, selector) {
3632
+ const xpath = xpathBodyForEvaluate(selector);
3633
+ if (xpath === null)
3634
+ return false;
3635
+ const expr = `(() => {
3636
+ const result = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3637
+ const el = result.singleNodeValue;
3638
+ if (!el) return false;
3639
+ ${segmented_date_spinbutton_1.SEGMENT_OWNER_JS}
3640
+ return isDateSegment(el);
3641
+ })()`;
3642
+ return (await target.evaluate(expr)) === true;
3643
+ }
3536
3644
  /**
3537
3645
  * Read back an element's value after a fill action and compare to the
3538
3646
  * expected value. Catches silent-value-rejection cases that the verifier's
@@ -3570,7 +3678,7 @@ async function verifyFillReadback(target, selector, expectedValue) {
3570
3678
  if (!el) return null;
3571
3679
  const tag = el.tagName ? el.tagName.toLowerCase() : "";
3572
3680
  let actual = "";
3573
- ${SEGMENT_OWNER_JS}
3681
+ ${segmented_date_spinbutton_1.SEGMENT_OWNER_JS}
3574
3682
  if (isDateSegment(el)) {
3575
3683
  return { outcome: "segment", postValue: el.value || el.getAttribute("aria-valuenow") || "", tag };
3576
3684
  }
@@ -4207,6 +4315,47 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
4207
4315
  return false;
4208
4316
  }
4209
4317
  }
4318
+ /**
4319
+ * Canonical key for an absolute xpath so Stagehand's `/html/body/div/button`
4320
+ * (index omitted when sole of its tag) and this module's `/html[1]/body[1]/
4321
+ * div[1]/button[1]` compare equal: a node's first-of-tag and sole-of-tag
4322
+ * forms can never coexist under one parent, so dropping `[1]` is injective.
4323
+ */
4324
+ function canonicalAbsoluteXpath(selector) {
4325
+ const body = xpathBodyForEvaluate(selector);
4326
+ return body?.startsWith("/") ? body.replace(/\[1\]/g, "").toLowerCase() : null;
4327
+ }
4328
+ /**
4329
+ * Canonical xpaths of every control on the page that is submit-shaped right
4330
+ * now, read before an act clicks anything. The act performs the click itself,
4331
+ * and a view swap removes the control with the page, so the only read that
4332
+ * cannot be blinded by the transition is one taken beforehand — and one that
4333
+ * does not depend on observe having resolved the same control the act later
4334
+ * clicks. Empty on any read failure: absence never manufactures a shape.
4335
+ */
4336
+ async function readSubmitShapedControlXpaths(target) {
4337
+ const expr = `(() => {
4338
+ const xpathOf = ${XPATH_OF_FN_SRC};
4339
+ const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
4340
+ const out = [];
4341
+ for (const el of document.querySelectorAll("button, input, a, [role='button'], [type='submit'], [onclick], [tabindex]")) {
4342
+ if (isSubmitShaped(el)) out.push(xpathOf(el));
4343
+ }
4344
+ return out;
4345
+ })()`;
4346
+ try {
4347
+ const result = await target.evaluate(expr);
4348
+ if (!Array.isArray(result))
4349
+ return new Set();
4350
+ return new Set(result
4351
+ .filter((x) => typeof x === "string")
4352
+ .map(canonicalAbsoluteXpath)
4353
+ .filter((x) => x !== null));
4354
+ }
4355
+ catch {
4356
+ return new Set();
4357
+ }
4358
+ }
4210
4359
  /**
4211
4360
  * True when the primary (Stagehand-recorded) xpath is unresolvable and only
4212
4361
  * `xpathTailForRetarget`'s loose tag+same-tag-sibling-position re-anchor
@@ -4523,12 +4672,17 @@ const UPLOAD_URL_PATTERNS = [
4523
4672
  "/media",
4524
4673
  "/import",
4525
4674
  ];
4675
+ /** Case-insensitive Content-Type lookup; CDP preserves the page's header casing. */
4676
+ function requestContentType(headers) {
4677
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === "content-type");
4678
+ return key ? (headers[key] ?? "") : "";
4679
+ }
4526
4680
  /**
4527
4681
  * Poll the recent-capture window for an upload-related POST after a file has
4528
4682
  * been attached. Extracted from `tryUploadPrimitive` so the raw-input path,
4529
4683
  * the click-to-surface path, and the CDP native-chooser path all verify the
4530
- * same way. Returns true as soon as a non-GET capture whose URL matches
4531
- * {@link UPLOAD_URL_PATTERNS} lands; false if the timeout elapses first.
4684
+ * same way. Returns true as soon as a non-GET capture that is multipart, or is not
4685
+ * known to be body-less and has a URL matching {@link UPLOAD_URL_PATTERNS}, lands; false if the timeout elapses first.
4532
4686
  */
4533
4687
  async function waitForUploadNetworkSignal(params) {
4534
4688
  const { page, fixture, logger, signalCounter, recentCaptureMeta, stopWhen } = params;
@@ -4541,6 +4695,10 @@ async function waitForUploadNetworkSignal(params) {
4541
4695
  const uploadCapture = newCaptures.find((cap) => {
4542
4696
  if (cap.method === "GET")
4543
4697
  return false;
4698
+ if (cap.multipart)
4699
+ return true;
4700
+ if (cap.hasBody === false)
4701
+ return false;
4544
4702
  const lowerUrl = cap.url.toLowerCase();
4545
4703
  return UPLOAD_URL_PATTERNS.some((p) => lowerUrl.includes(p));
4546
4704
  });
@@ -4672,8 +4830,9 @@ async function tryUploadPrimitive(params) {
4672
4830
  * Attach the fixture to an already-surfaced `<input type=file>` (raw or freshly
4673
4831
  * mounted after a click) and verify. Extracted verbatim from the original
4674
4832
  * `tryUploadPrimitive` body so the raw-input path and the click-to-surface
4675
- * path share one setInputFiles + framework-change-dispatch + network/DOM verify
4676
- * + drag-drop-fallback implementation.
4833
+ * path share one setInputFiles + framework-change-dispatch + network/DOM verify implementation.
4834
+ * Returns true only on a verified signal
4835
+ * (upload POST or populated `.files`).
4677
4836
  */
4678
4837
  async function attachToSurfacedInput(params) {
4679
4838
  const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
@@ -4740,18 +4899,10 @@ async function attachToSurfacedInput(params) {
4740
4899
  .evaluate("(() => { const els = document.querySelectorAll('input[type=file]'); for (const el of els) { if (el.files && el.files.length > 0) return el.files.length; } return 0; })()")
4741
4900
  .catch(() => 0);
4742
4901
  if (typeof attachedLength !== "number" || attachedLength === 0) {
4743
- logger.warn("upload primitive: no network activity within timeout and no file attached in DOM; attempting drag-drop fallback");
4744
- // K'4: fall back to simulated drag-drop on the most-likely drop zone.
4745
- // Some custom upload widgets (Material Dropzone, react-dropzone, custom
4746
- // <uapp-upload> wrappers) only register files when a `drop` event with
4747
- // a DataTransfer fires on the visible drop area — they don't observe
4748
- // the hidden input's `files[]` mutations even with synthetic `change`
4749
- // dispatches. This is the documented Playwright community workaround.
4750
- const dragDropOk = await simulateDragDropUpload(target, fixture, logger);
4751
- if (dragDropOk) {
4752
- logger.info(`upload primitive: drag-drop fallback succeeded (name=${fixture.name}, size=${fixture.buffer.length}b)`);
4753
- return true;
4754
- }
4902
+ // A dispatched drop is unverified (no network signal, no .files), so it is
4903
+ // not success here: return false and let surfaceAndUpload run the verified
4904
+ // drop and the trusted-click/chooser routes.
4905
+ logger.warn("upload primitive: no network activity within timeout and no file attached in DOM; deferring to surface-and-upload");
4755
4906
  return false;
4756
4907
  }
4757
4908
  logger.info(`upload primitive: file attached in DOM after setInputFiles (deferred-upload widget; name=${fixture.name}, filesLength=${attachedLength})`);
@@ -5408,7 +5559,7 @@ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5408
5559
  // A group under another heading is not the field the step names, however
5409
5560
  // well its label matches: the step's own words must find the section.
5410
5561
  // A section that only repeats the group's own label is no heading at all
5411
- // (Workday enumerates "From*" as both), so it cannot name another section.
5562
+ // (a widget may enumerate "From*" as both), so it cannot name another section.
5412
5563
  const namesOtherSection = (group) => {
5413
5564
  const section = segmentedDateTokens(group.section ?? "");
5414
5565
  const label = segmentedDateTokens(group.label ?? "");
@@ -5469,7 +5620,7 @@ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5469
5620
  if (leaders.length > 1) {
5470
5621
  // A nested group is one wrapper's slice of a larger date widget (e.g. the
5471
5622
  // Year half of "From MM/YYYY"), so it only ties with a standalone group of
5472
- // the same label when no section text separates them — Workday renders
5623
+ // the same label when no section text separates them — a form may render
5473
5624
  // "Work Experience 1 From" and "Education 1 From" with no heading the
5474
5625
  // enumerator can see. The standalone group is the field the step names.
5475
5626
  const standalone = leaders.filter((i) => !groups[i]?.nested);
@@ -5482,71 +5633,13 @@ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5482
5633
  const only = groups.flatMap((g, i) => (compatible(g) && !g.nested ? [i] : []));
5483
5634
  return only.length === 1 ? (only[0] ?? null) : null;
5484
5635
  }
5485
- /**
5486
- * In-page helpers shared by the date-segment enumeration and the fill
5487
- * readback so "is this spinbutton one segment of a multi-segment widget" has a
5488
- * single definition; defines `spins`, `ownerOf` and `isDateSegment`.
5489
- */
5490
- const SEGMENT_OWNER_JS = `
5491
- const hiddenAttr = (node) => node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true";
5492
- const styleOf = (node) => {
5493
- try { return typeof getComputedStyle === "function" ? getComputedStyle(node) : window.getComputedStyle(node); } catch { return null; }
5494
- };
5495
- const isHidden = (el) => {
5496
- for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
5497
- if (hiddenAttr(node)) return true;
5498
- const style = styleOf(node);
5499
- if (style && (style.display === "none" || style.visibility === "hidden")) return true;
5500
- }
5501
- return false;
5502
- };
5503
- const spins = Array.from(document.querySelectorAll("input[role='spinbutton']")).filter((el) => !isHidden(el));
5504
- const norm = (s) => (s || "").replace(/\\s+/g, " ").trim();
5505
- const idText = (ids) => (ids || "").split(/\\s+/).map((id) => {
5506
- const ref = id ? document.getElementById(id) : null;
5507
- return ref ? norm(ref.textContent) : "";
5508
- }).join(" ");
5509
- const legendOf = (node) => {
5510
- const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
5511
- return legend ? norm(legend.textContent) : "";
5512
- };
5513
- const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
5514
- const tiedToSpin = (l) => l.querySelector("input,[role='spinbutton']") || (l.getAttribute("for") && spins.some((sp) => sp.id === l.getAttribute("for")));
5515
- const hasCaption = (node) => namedBy(node) !== "" || Array.from(node.querySelectorAll("label,legend")).some((l) => !tiedToSpin(l));
5516
- const captionOwnerOf = (el) => {
5517
- const explicit = el.closest("fieldset,[role='group']");
5518
- if (explicit) return explicit;
5519
- for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5520
- if (hasCaption(node)) return node;
5521
- }
5522
- return null;
5523
- };
5524
- const bareSpins = spins.filter((sp) => !captionOwnerOf(sp));
5525
- const hasOtherControl = (node) => Array.from(node.querySelectorAll("input,select,textarea")).some((c) => !spins.includes(c) && c.type !== "hidden");
5526
- // Segments that each sit in their own uncaptioned wrapper share no
5527
- // fieldset/caption, so the nearest ancestor holding several bare spinbuttons
5528
- // (and no other form control) is the widget they belong to.
5529
- const bareOwnerOf = (el) => {
5530
- for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5531
- if (hasOtherControl(node)) break;
5532
- if (bareSpins.filter((sp) => node.contains(sp)).length > 1) return node;
5533
- }
5534
- return el.parentElement;
5535
- };
5536
- const ownerOf = (el) => captionOwnerOf(el) || bareOwnerOf(el);
5537
- const isDateSegment = (el) => {
5538
- if (!el.getAttribute || el.getAttribute("role") !== "spinbutton" || !spins.includes(el)) return false;
5539
- const box = ownerOf(el);
5540
- return spins.filter((sp) => ownerOf(sp) === box).length > 1;
5541
- };
5542
- `;
5543
5636
  const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
5544
5637
  /** Exported so tests can run the real DOM enumeration against markup. */
5545
5638
  exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5546
5639
  const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
5547
5640
  const stale = document.querySelectorAll("[" + attr + "]");
5548
5641
  stale.forEach((el) => el.removeAttribute(attr));
5549
- ${SEGMENT_OWNER_JS}
5642
+ ${segmented_date_spinbutton_1.SEGMENT_OWNER_JS}
5550
5643
  if (spins.length === 0) return { groups: [] };
5551
5644
  const headingSibling = (node) => {
5552
5645
  let prevSib = node.previousElementSibling;
@@ -5600,15 +5693,21 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5600
5693
  const prior = seg.getAttribute(attr);
5601
5694
  seg.setAttribute(attr, prior ? prior + " " + token : token);
5602
5695
  };
5603
- const addGroup = (segs, box, nested) => {
5696
+ const addGroup = (segs, box, nested, outer) => {
5604
5697
  const gi = groups.length;
5605
5698
  segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5606
5699
  let inner = null;
5607
- for (let node = segs[0].parentElement; node && node !== box && box.contains(node) && !inner; node = node.parentElement) {
5608
- if (segs.every((seg) => node.contains(seg)) && hasCaption(node)) inner = node;
5609
- }
5700
+ // A wrapper holding one segment is captioned by the nearest ancestor that
5701
+ // holds that segment and no other: a caption over several segments names
5702
+ // the whole date, never one slice of it.
5703
+ const top = outer || box;
5704
+ const soleOwner = (node) => !outer || spins.filter((sp) => node.contains(sp)).length === segs.length;
5705
+ for (let node = segs[0].parentElement; node && node !== top && top.contains(node) && !inner; node = node.parentElement) {
5706
+ if (segs.every((seg) => node.contains(seg)) && hasCaption(node) && soleOwner(node)) inner = node;
5707
+ }
5708
+ if (inner === box) inner = null;
5610
5709
  const labelBox = inner || box;
5611
- const own = namedBy(labelBox) || ownCaption(labelBox) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ");
5710
+ const own = namedBy(labelBox) || ownCaption(labelBox) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ") || Array.from(new Set(segs.map((seg) => ownKey(seg)).filter(Boolean))).join(" ");
5612
5711
  const section = sectionOf(labelBox, Boolean(inner));
5613
5712
  groups.push({
5614
5713
  segmentMaxes: segs.map((seg, si) => maxOf(seg, si, segs.length)),
@@ -5631,7 +5730,7 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5631
5730
  }
5632
5731
  const wrap = seg.closest("label");
5633
5732
  if (wrap) parts.push(norm(wrap.textContent));
5634
- return keyOf(parts.filter(Boolean).join(" "));
5733
+ return keyOf(Array.from(new Set(parts.filter(Boolean))).join(" "));
5635
5734
  };
5636
5735
  // A container's legend names the whole section; the field a segment belongs
5637
5736
  // to is the caption naming it, or the caption nearest before it with no other
@@ -5645,8 +5744,15 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5645
5744
  if (el === boxLegend) return;
5646
5745
  if (el.matches("label,legend")) {
5647
5746
  const target = el.getAttribute("for");
5648
- if (isHidden(el) || el.querySelector("input,select,textarea,[role='spinbutton']") || (target && box.querySelector("[id='" + target + "']"))) return;
5649
- current = keyOf(el.textContent);
5747
+ if (isHidden(el) || el.querySelector("input,select,textarea,[role='spinbutton']")) return;
5748
+ const text = keyOf(el.textContent);
5749
+ // A label tied to one of these segments names the field that segment
5750
+ // opens; its text is only a part word ("Month") when it names nothing.
5751
+ if (target && box.querySelector("[id='" + target + "']")) {
5752
+ if (own.has(box.querySelector("[id='" + target + "']")) && text) current = text;
5753
+ return;
5754
+ }
5755
+ current = text;
5650
5756
  return;
5651
5757
  }
5652
5758
  if (!own.has(el)) {
@@ -5669,10 +5775,8 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5669
5775
  }
5670
5776
  // A shared fieldset can hold several independent dates (From and To year
5671
5777
  // spinbuttons side by side); each wrapper is then its own candidate group.
5672
- if (byParent.size > 1) {
5673
- byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
5674
- return;
5675
- }
5778
+ const split = byParent.size > 1;
5779
+ if (split) byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent, box]));
5676
5780
  const keys = fieldKeys(segs, box);
5677
5781
  const runs = [];
5678
5782
  segs.forEach((seg, si) => {
@@ -5680,9 +5784,13 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5680
5784
  if (last && last.key === keys[si]) last.segs.push(seg);
5681
5785
  else runs.push({ key: keys[si], segs: [seg] });
5682
5786
  });
5683
- runs.filter((run) => run.key).forEach((run) => captionRuns.push({ run, box, outerIndex, whole: run.segs.length === segs.length }));
5787
+ // Segments that each sit in their own display wrapper still form one date
5788
+ // when a shared caption names them, so the caption run is a candidate too;
5789
+ // a run that is exactly one wrapper's segments is already that wrapper's group.
5790
+ const isWrapperGroup = (run) => split && byParent.get(run.segs[0].parentElement).length === run.segs.length && run.segs.every((seg) => seg.parentElement === run.segs[0].parentElement);
5791
+ runs.filter((run) => run.key && !isWrapperGroup(run)).forEach((run) => captionRuns.push({ run, box, outerIndex, whole: run.segs.length === segs.length }));
5684
5792
  });
5685
- nestedCandidates.forEach(([inner, parent]) => addGroup(inner, parent, true));
5793
+ nestedCandidates.forEach(([inner, parent, outer]) => addGroup(inner, parent, true, outer));
5686
5794
  captionRuns.forEach(({ run, box, outerIndex, whole }) => {
5687
5795
  const outer = groups[outerIndex];
5688
5796
  const same = (a, b) => a.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() === b.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
@@ -5711,21 +5819,38 @@ const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
5711
5819
  *
5712
5820
  * Why this exists: such widgets keep each segment's value in component state
5713
5821
  * driven by `keydown`/`beforeinput`; `fill`/`insertText` bypasses that and
5714
- * garbles the segments. Returns a marker id on commit, or null on no
5715
- * spinbuttons, an unresolvable group, or any segment mismatch so the caller
5716
- * falls through to the cascade.
5822
+ * garbles the segments. Returns a marker id on commit, or null on any
5823
+ * non-commit. Callers that must tell "not applicable" from "refused" use
5824
+ * `runSegmentedDateSpinbuttonPrimitive`.
5717
5825
  */
5718
5826
  async function trySegmentedDateSpinbuttonPrimitive(params) {
5827
+ const outcome = await runSegmentedDateSpinbuttonPrimitive(params);
5828
+ return outcome.kind === "committed" ? outcome.targetId : null;
5829
+ }
5830
+ /**
5831
+ * Same as `trySegmentedDateSpinbuttonPrimitive` but distinguishes "this step
5832
+ * is not a segmented date here" (fall through to the cascade) from "a
5833
+ * width-compatible segment group exists and the primitive could not deliver"
5834
+ * (refused: the cascade would write the value via insertText and garble the
5835
+ * segments, so the caller must fail the step instead).
5836
+ */
5837
+ async function runSegmentedDateSpinbuttonPrimitive(params) {
5719
5838
  const { page, target, instruction, logger } = params;
5839
+ const notApplicable = { kind: "not-applicable" };
5840
+ const refuse = (reason) => {
5841
+ logger.warn(`segmented date primitive: refused: ${reason}`);
5842
+ return { kind: "refused", reason };
5843
+ };
5844
+ let groupChosen = false;
5720
5845
  const whole = parseSegmentedDateValue(instruction);
5721
5846
  const part = parseSegmentedPartStep(instruction);
5722
5847
  if (!whole && !part)
5723
- return null;
5848
+ return notApplicable;
5724
5849
  const sendCDP = frameCdpSender(page, target);
5725
5850
  try {
5726
5851
  const enumerated = (await target.evaluate(exports.SEGMENTED_DATE_ENUMERATE_EXPR));
5727
5852
  if (!enumerated?.groups?.length)
5728
- return null;
5853
+ return notApplicable;
5729
5854
  const wholeGi = whole ? resolveSegmentedDateGroup(instruction, enumerated.groups) : null;
5730
5855
  const partStep = wholeGi === null ? part : null;
5731
5856
  const value = (wholeGi !== null ? whole : part);
@@ -5741,10 +5866,18 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5741
5866
  ? pickSegmentedDateGroup(instruction, enumerated.groups, partStep, partFits)
5742
5867
  : null);
5743
5868
  if (gi === null) {
5744
- logger.info("segmented date primitive: no unambiguous group; falling through");
5745
5869
  await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5746
- return null;
5870
+ const widthCompatible = enumerated.groups.some((group) => (whole !== null &&
5871
+ group.segmentMaxes.reduce((sum, max) => sum + String(max).length, 0) ===
5872
+ whole.digits.length) ||
5873
+ (partStep !== null && partFits(group)));
5874
+ if (widthCompatible) {
5875
+ return refuse("a width-compatible date group exists but the target group is ambiguous");
5876
+ }
5877
+ logger.info("segmented date primitive: no compatible group; falling through");
5878
+ return notApplicable;
5747
5879
  }
5880
+ groupChosen = true;
5748
5881
  const maxes = enumerated.groups[gi]?.segmentMaxes ?? [];
5749
5882
  const widths = maxes.map((max) => String(max).length);
5750
5883
  const onlySegment = partStep ? partOf(enumerated.groups[gi]) : null;
@@ -5841,15 +5974,16 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5841
5974
  actual = await readSegments();
5842
5975
  await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5843
5976
  if (!matches(actual)) {
5844
- logger.info(`segmented date primitive: readback ${maskDateDigits(JSON.stringify(actual))} != ${maskDateDigits(JSON.stringify(expected))}; falling through`);
5845
- return null;
5977
+ return refuse(`readback ${maskDateDigits(JSON.stringify(actual))} != ${maskDateDigits(JSON.stringify(expected))}`);
5846
5978
  }
5847
5979
  logger.info(`segmented date primitive: committed segments ${maskDateDigits(JSON.stringify(actual))}`);
5848
- return `segmented-date-${gi}`;
5980
+ return { kind: "committed", targetId: `segmented-date-${gi}` };
5849
5981
  }
5850
5982
  catch (err) {
5983
+ if (groupChosen)
5984
+ return refuse(`delivery threw: ${(0, errors_1.toErrorMessage)(err)}`);
5851
5985
  logger.warn(`segmented date primitive: threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5852
- return null;
5986
+ return notApplicable;
5853
5987
  }
5854
5988
  }
5855
5989
  /**
@@ -6215,15 +6349,103 @@ function isNavigatingEvaluateRejection(error) {
6215
6349
  message.includes("no frame for given id found") ||
6216
6350
  GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES.has(message.trim()));
6217
6351
  }
6352
+ const DEFAULT_POST_CLICK_SETTLE_IO = {
6353
+ settle: settleWithPageQuiescence,
6354
+ snapshot: snapshotPage,
6355
+ };
6356
+ /**
6357
+ * Re-settle rounds granted to a post-click page whose header flipped but whose
6358
+ * body has not re-rendered yet. Each round waits the settle's own idle
6359
+ * allowance, so the rounds together span the settle's MAX_MS ceiling rather
6360
+ * than a fixed guess; a held-still text ends the loop sooner.
6361
+ */
6362
+ const HEADER_ONLY_RESETTLE_ROUNDS = Math.floor(click_settle_1.MAX_MS / click_settle_1.IDLE_GIVE_UP_MS);
6363
+ /**
6364
+ * Re-reads the post-click page while only its header has flipped. One
6365
+ * settle-then-snapshot moment can land between a header flip and the body
6366
+ * re-render that follows it, so the verdict waits for the body or for the text
6367
+ * to hold still through the whole re-settle budget, and only then reads the
6368
+ * header flip as the page's final state (`settled`): a single quiet round
6369
+ * cannot distinguish a finished page from a body that re-renders later. Each round settles against the
6370
+ * pre-click text, so the header flip stays movement and the quiet window, not the silent no-op
6371
+ * give-up, decides how long a body re-render is waited for. Bounded; a page whose body already swapped
6372
+ * returns at once, and one showing no change gets a single probe round so a
6373
+ * re-render that lands after the first snapshot is still seen.
6374
+ */
6375
+ async function resettleThroughHeaderOnlyFlip(page, target, signalCounter, pre, post, io = DEFAULT_POST_CLICK_SETTLE_IO) {
6376
+ let current = post;
6377
+ for (let round = 0; round < HEADER_ONLY_RESETTLE_ROUNDS; round++) {
6378
+ const moved = (0, phantom_click_1.hasVisibleTextChanged)(pre, current);
6379
+ if (moved && (0, phantom_click_1.hasPageBodySwapped)(pre, current))
6380
+ return round > 0 ? { ...current, settled: true } : current;
6381
+ if (!moved) {
6382
+ // A click unseen to move anything gets one settle-free re-read, so a genuine
6383
+ // no-op adds no wait to its give-up bound.
6384
+ if (round > 0)
6385
+ return current;
6386
+ const probe = await io.snapshot(target, signalCounter, page);
6387
+ if (!(0, phantom_click_1.hasVisibleTextChanged)(pre, probe))
6388
+ return current;
6389
+ current = probe;
6390
+ continue;
6391
+ }
6392
+ await io.settle(page, target, signalCounter, current.networkCount, true, pre.visibleTextSignature, pre.visibleTextDigest);
6393
+ const next = await io.snapshot(target, signalCounter, page);
6394
+ const heldStill = !(0, phantom_click_1.hasVisibleTextChanged)(current, next);
6395
+ current = next;
6396
+ if (heldStill && !(0, phantom_click_1.hasPageBodySwapped)(pre, current))
6397
+ return { ...current, settled: true };
6398
+ }
6399
+ return current;
6400
+ }
6401
+ /**
6402
+ * Text baseline of the step-start page with the attempt's own network count, so
6403
+ * a late re-render an earlier attempt already absorbed into `pre` still reads as
6404
+ * movement to the settle while only this click's requests count as its own.
6405
+ */
6406
+ function withNetworkBaseline(baseline, pre) {
6407
+ return { ...baseline, networkCount: pre.networkCount };
6408
+ }
6409
+ /**
6410
+ * Single owner of the settle -> snapshot -> header-only resettle sequence, so
6411
+ * every post-click site applies the same settled-ness rule. A click that cannot
6412
+ * trigger a round-trip is snapshotted as-is. The resettle does not depend on
6413
+ * `waitForMutations`, since an unflagged step can still advance a page whose
6414
+ * body re-renders late.
6415
+ */
6416
+ async function settleSnapshotAndResettle(target, signalCounter, page, pre, mayTriggerRoundTrip, waitForMutations, io) {
6417
+ if (page && mayTriggerRoundTrip)
6418
+ await io.settle(page, target, signalCounter, pre.networkCount, waitForMutations, pre.visibleTextSignature, pre.visibleTextDigest);
6419
+ const post = await io.snapshot(target, signalCounter, page);
6420
+ return page && mayTriggerRoundTrip
6421
+ ? resettleThroughHeaderOnlyFlip(page, target, signalCounter, pre, post, io)
6422
+ : post;
6423
+ }
6218
6424
  /**
6219
6425
  * Settles after a click and returns the post-click snapshot, so the settle
6220
6426
  * wait and the phantom classifier decide from the same reader and baseline.
6221
6427
  * Without a page there is nothing to wait on and the snapshot is taken as-is.
6428
+ * `io` lets a caller that owns its own seam (recon-browser's pre-replan
6429
+ * short-circuit) route the settle and the snapshot through the functions it
6430
+ * already imports, so the settled read there is the same one every other site
6431
+ * makes.
6432
+ */
6433
+ async function settleAndSnapshotPostClick(target, signalCounter, page, pre, waitForMutations = true, io = DEFAULT_POST_CLICK_SETTLE_IO) {
6434
+ return settleSnapshotAndResettle(target, signalCounter, page, pre, true, waitForMutations, io);
6435
+ }
6436
+ /**
6437
+ * Single mid-flow post-click advance read: picks the advance baseline (the
6438
+ * step-start snapshot for a mid-flow submit, `pre` otherwise), gives it the
6439
+ * attempt's network count, settles, re-settles through a header-only flip and
6440
+ * judges advance against that same baseline, so every site credits a page that
6441
+ * advanced from step start once it is quiescent.
6222
6442
  */
6223
- async function settleAndSnapshotPostClick(target, signalCounter, page, pre) {
6224
- if (page)
6225
- await settleWithPageQuiescence(page, target, signalCounter, pre.networkCount, true, pre.visibleTextSignature);
6226
- return snapshotPage(target, signalCounter, page);
6443
+ async function readMidFlowPostClickAdvance(args) {
6444
+ const advanceBaseline = args.midFlowSubmit
6445
+ ? withNetworkBaseline(args.stepStartSnapshot, args.pre)
6446
+ : args.pre;
6447
+ const post = await settleSnapshotAndResettle(args.target, args.signalCounter, args.page, advanceBaseline, args.mayTriggerRoundTrip ?? true, args.waitForMutations ?? true, args.io ?? DEFAULT_POST_CLICK_SETTLE_IO);
6448
+ return { post, advanceBaseline, pageAdvanced: (0, phantom_click_1.hasSettledPageAdvanced)(advanceBaseline, post) };
6227
6449
  }
6228
6450
  /**
6229
6451
  * Shared in-page discovery of the widget's callback, used identically by
@@ -6451,6 +6673,38 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
6451
6673
  await target.evaluate(dispatchChangeExpr).catch(() => undefined);
6452
6674
  return { injected, hasForm, callbackDiscovered };
6453
6675
  }
6676
+ /**
6677
+ * Single assembly point for the mid-flow half of a phantom-click verdict, so
6678
+ * the primary attempt and every bypass/retry site extend the same
6679
+ * pre-URL destination exemption for a mid-flow submit, granted only on a
6680
+ * settled page advance, identically instead of each re-deriving it.
6681
+ */
6682
+ function midFlowDestinationPlausible(params) {
6683
+ const { step, post, midFlowSubmit } = params;
6684
+ const baseline = params.advanceBaseline;
6685
+ return ((params.destinationPlausible ?? (0, phantom_click_1.isPlausibleStepDestination)(step, post.url)) ||
6686
+ (midFlowSubmit &&
6687
+ (0, phantom_click_1.hasSettledPageAdvanced)(baseline, post) &&
6688
+ (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, baseline.url)));
6689
+ }
6690
+ /**
6691
+ * Classifies a submit-shaped click through the shared mid-flow verdict
6692
+ * assembly used by the primary path and the n+16 / deep-submit / captcha /
6693
+ * child-frame bypass sites.
6694
+ */
6695
+ function classifySubmitShapedClick(params) {
6696
+ const { pre, post, midFlowSubmit } = params;
6697
+ return (0, phantom_click_1.classifyPhantomClick)({
6698
+ actResultSuccess: params.actResultSuccess ?? true,
6699
+ pre,
6700
+ post,
6701
+ elementStateChanged: params.elementStateChanged,
6702
+ isSubmitShapedStep: midFlowSubmit || (params.isSubmitShapedStep ?? true),
6703
+ isMidFlowSubmitStep: midFlowSubmit,
6704
+ advanceBaseline: params.advanceBaseline,
6705
+ destinationPlausible: midFlowDestinationPlausible(params),
6706
+ });
6707
+ }
6454
6708
  /**
6455
6709
  * Explicit submit fallback for {@link injectCaptchaTokenAndSubmit} callers:
6456
6710
  * used only when the caller's own transition poll observed no advance after
@@ -6522,6 +6776,8 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6522
6776
  const pre = verification
6523
6777
  ? await snapshotPage(target, verification.signalCounter, verification.page)
6524
6778
  : null;
6779
+ if (pre)
6780
+ await armPageQuiescence(target);
6525
6781
  const clickResult = await target
6526
6782
  .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))
6527
6783
  .catch((err) => {
@@ -6532,14 +6788,30 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6532
6788
  if (clickResult.clicked) {
6533
6789
  if (!pre || !verification)
6534
6790
  return true;
6535
- const post = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, pre);
6536
- const verdict = (0, phantom_click_1.classifyPhantomClick)({
6537
- actResultSuccess: true,
6791
+ // An omitted flag means the caller could not tell final from mid-flow;
6792
+ // derive from the step like the in-cascade sites rather than going strict.
6793
+ const midFlowSubmit = verification.isMidFlowSubmitStep ??
6794
+ isMidFlowSubmitShapedStep({
6795
+ step: verification.step,
6796
+ submitStep: false,
6797
+ isFinalStep: false,
6798
+ flowHasSubmitSemantics: false,
6799
+ elementSubmitShaped: true,
6800
+ });
6801
+ const { post, advanceBaseline } = await readMidFlowPostClickAdvance({
6802
+ target,
6803
+ signalCounter: verification.signalCounter,
6804
+ page: verification.page,
6805
+ pre,
6806
+ stepStartSnapshot: verification.stepStartSnapshot ?? pre,
6807
+ midFlowSubmit,
6808
+ });
6809
+ const verdict = classifySubmitShapedClick({
6810
+ step: verification.step,
6538
6811
  pre,
6539
6812
  post,
6540
- isSubmitShapedStep: true,
6541
- isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6542
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url, verification.isMidFlowSubmitStep ? pre.url : undefined),
6813
+ midFlowSubmit,
6814
+ advanceBaseline,
6543
6815
  });
6544
6816
  if (verdict !== "phantom")
6545
6817
  return true;
@@ -6553,14 +6825,20 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6553
6825
  throw err;
6554
6826
  });
6555
6827
  if (runnerUpClickResult.clicked) {
6556
- const runnerUpPost = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, post);
6557
- const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
6558
- actResultSuccess: true,
6828
+ const { post: runnerUpPost, advanceBaseline: runnerUpBaseline } = await readMidFlowPostClickAdvance({
6829
+ target,
6830
+ signalCounter: verification.signalCounter,
6831
+ page: verification.page,
6832
+ pre: post,
6833
+ stepStartSnapshot: verification.stepStartSnapshot ?? pre,
6834
+ midFlowSubmit,
6835
+ });
6836
+ const runnerUpVerdict = classifySubmitShapedClick({
6837
+ step: verification.step,
6559
6838
  pre: post,
6560
6839
  post: runnerUpPost,
6561
- isSubmitShapedStep: true,
6562
- isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6563
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url, verification.isMidFlowSubmitStep ? post.url : undefined),
6840
+ midFlowSubmit,
6841
+ advanceBaseline: runnerUpBaseline,
6564
6842
  });
6565
6843
  if (runnerUpVerdict !== "phantom")
6566
6844
  return true;
@@ -8766,6 +9044,9 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
8766
9044
  * upload/dropzone/file-related class or tag names. Tries multiple
8767
9045
  * candidates in order. Returns true on first success.
8768
9046
  *
9047
+ * Dispatch-only: a true return means the drop events fired, not that the
9048
+ * widget accepted the file; callers must verify.
9049
+ *
8769
9050
  * Site-agnostic — DataTransfer + DragEvent dispatch is universal HTML5
8770
9051
  * drag-and-drop API. Works on react-dropzone, Material Dropzone, custom
8771
9052
  * <uapp-upload>/<app-upload>, and any other drop-zone-based upload UI.
@@ -9821,6 +10102,7 @@ function primitiveOptionLabel(option, questionLabel, redactValue, fromFill = fal
9821
10102
  */
9822
10103
  async function probeChildFrameSubmitFallback(params) {
9823
10104
  const { page, signalCounter, step, isMidFlowSubmitStep } = params;
10105
+ const midFlowSubmit = isMidFlowSubmitStep === true;
9824
10106
  // `page.mainFrameId`/`page.frames` are read defensively: some call sites
9825
10107
  // (and their test fakes) model a `Page` that never attaches child frames at
9826
10108
  // all and doesn't implement this pair, which is indistinguishable here from
@@ -9870,21 +10152,27 @@ async function probeChildFrameSubmitFallback(params) {
9870
10152
  // biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
9871
10153
  const top = ranked[0];
9872
10154
  const pre = await snapshotPage(target, signalCounter, page);
10155
+ await armPageQuiescence(target);
9873
10156
  const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
9874
10157
  if (!clickResult.clicked)
9875
10158
  continue;
9876
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
9877
- const snap = await snapshotPage(target, signalCounter);
9878
- return snap.visibleTextSignature;
9879
- }, pre.visibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
9880
- const post = await snapshotPage(target, signalCounter, page);
9881
- const verdict = (0, phantom_click_1.classifyPhantomClick)({
9882
- actResultSuccess: true,
10159
+ // The step-start snapshot describes the resolved frame, not this child
10160
+ // frame, so the first click's own pre-click page is the baseline both
10161
+ // the top pick and the runner-up are judged against.
10162
+ const { post, advanceBaseline } = await readMidFlowPostClickAdvance({
10163
+ target,
10164
+ signalCounter,
10165
+ page,
10166
+ pre,
10167
+ stepStartSnapshot: pre,
10168
+ midFlowSubmit,
10169
+ });
10170
+ const verdict = classifySubmitShapedClick({
10171
+ step,
9883
10172
  pre,
9884
10173
  post,
9885
- isSubmitShapedStep: true,
9886
- isMidFlowSubmitStep,
9887
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, isMidFlowSubmitStep ? pre.url : undefined),
10174
+ midFlowSubmit,
10175
+ advanceBaseline,
9888
10176
  });
9889
10177
  if (verdict !== "phantom")
9890
10178
  return target;
@@ -9894,18 +10182,20 @@ async function probeChildFrameSubmitFallback(params) {
9894
10182
  const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
9895
10183
  if (!runnerUpClickResult.clicked)
9896
10184
  continue;
9897
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, post.networkCount, true, async () => {
9898
- const snap = await snapshotPage(target, signalCounter);
9899
- return snap.visibleTextSignature;
9900
- }, post.visibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
9901
- const runnerUpPost = await snapshotPage(target, signalCounter, page);
9902
- const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
9903
- actResultSuccess: true,
9904
- pre,
10185
+ const { post: runnerUpPost } = await readMidFlowPostClickAdvance({
10186
+ target,
10187
+ signalCounter,
10188
+ page,
10189
+ pre: midFlowSubmit ? post : advanceBaseline,
10190
+ stepStartSnapshot: advanceBaseline,
10191
+ midFlowSubmit,
10192
+ });
10193
+ const runnerUpVerdict = classifySubmitShapedClick({
10194
+ step,
10195
+ pre: post,
9905
10196
  post: runnerUpPost,
9906
- isSubmitShapedStep: true,
9907
- isMidFlowSubmitStep,
9908
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url, isMidFlowSubmitStep ? post.url : undefined),
10197
+ midFlowSubmit,
10198
+ advanceBaseline,
9909
10199
  });
9910
10200
  if (runnerUpVerdict !== "phantom")
9911
10201
  return target;
@@ -9939,7 +10229,14 @@ async function raceAbortSignal(work, signal) {
9939
10229
  }
9940
10230
  }
9941
10231
  async function executeStepWithHealing(params) {
9942
- const { stagehand, optional, upload, submitStep, captchaGated = false, emailStep = false, emailStepConfig, allocatedInbox = null, sessionProxy = null, flowHasSubmitSemantics: flowHasSubmitSemanticsFlag, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, onStepHeal, } = params;
10232
+ const { stagehand, optional, upload, submitStep, captchaGated = false, emailStep = false, emailStepConfig, allocatedInbox = null, sessionProxy = null, flowHasSubmitSemantics: flowHasSubmitSemanticsParam, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, onStepHeal, onElementStepStartBaseline, } = params;
10233
+ // An explicitly flagged final step is the flow's submit by definition (see
10234
+ // flowHasSubmitSemantics). An omitted flow-level flag is derived from the same
10235
+ // evidence flowHasSubmitSemantics reads (a submit flag, an endpoint pattern or
10236
+ // requirement), so a last slot with none of them is mid-flow rather than a
10237
+ // final submit held to network/URL proof.
10238
+ const flowSubmitEvidence = submitStep || submitEndpointPattern !== null || requireSubmitEndpointMatch;
10239
+ const flowHasSubmitSemanticsFlag = (flowHasSubmitSemanticsParam ?? flowSubmitEvidence) || (isFinalStep && submitStep);
9943
10240
  // Mutable: the `emailStep` code-extract path splices the extracted code
9944
10241
  // into this instruction (see the emailStep hook block below) so the
9945
10242
  // pre-existing fill cascade downstream picks it up as the value to type
@@ -10119,7 +10416,11 @@ async function executeStepWithHealing(params) {
10119
10416
  const stepDeclaredNonSubmit = isStepDeclaredNonSubmit({ step, submitStep });
10120
10417
  // Single final-step inference every submit-strictness gate below reads, so a
10121
10418
  // declared non-submit veto cannot be honoured at one site and missed at another.
10122
- const inferredFinalSubmitStep = isFinalStep && !stepDeclaredNonSubmit;
10419
+ // Finality goes through isGenuineFinalSubmit like the judge routing and
10420
+ // isMidFlowSubmitShapedStep: a last slot without submit semantics is mid-flow
10421
+ // and must not be held to final-submit proof here.
10422
+ const inferredFinalSubmitStep = isGenuineFinalSubmit({ isFinalStep, flowHasSubmitSemantics: flowHasSubmitSemanticsFlag }) &&
10423
+ !stepDeclaredNonSubmit;
10123
10424
  const requireSubmitEndpoint = (inferredFinalSubmitStep || submitStep) && submitEndpointPattern !== null;
10124
10425
  // Capture the per-element selection baseline (for verifyDomEffect's element-
10125
10426
  // scoped click read-back) ONLY for selection/field-answer click steps — never
@@ -10257,15 +10558,18 @@ async function executeStepWithHealing(params) {
10257
10558
  }
10258
10559
  // Multi-segment date spinbuttons need real key events; the cascade's
10259
10560
  // insertText would garble the segments. Falls through on null.
10260
- const segmentedDateTargetId = await trySegmentedDateSpinbuttonPrimitive({
10561
+ const segmentedDate = await runSegmentedDateSpinbuttonPrimitive({
10261
10562
  page,
10262
10563
  target: selectFrameTarget,
10263
10564
  instruction: step,
10264
10565
  logger,
10265
10566
  });
10266
- if (segmentedDateTargetId !== null) {
10567
+ if (segmentedDate.kind === "refused") {
10568
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactPii)(step).slice(0, 60)}) segmented date could not be delivered: ${segmentedDate.reason}`, "cascade-exhausted");
10569
+ }
10570
+ if (segmentedDate.kind === "committed") {
10267
10571
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by segmented-date primitive`);
10268
- trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: segmentedDateTargetId });
10572
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: segmentedDate.targetId });
10269
10573
  return "completed";
10270
10574
  }
10271
10575
  // On a CATCH-ALL step ("for any remaining … question"), fill every
@@ -10306,6 +10610,9 @@ async function executeStepWithHealing(params) {
10306
10610
  const isSettleWaitStep = isSubmitOrFinalStep || isAdvanceStep(step);
10307
10611
  if (captchaGated && isSubmitOrFinalStep) {
10308
10612
  let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10613
+ // The step-level baseline is declared after this block, so capture the
10614
+ // step-start page here for the fallback submit to judge against.
10615
+ const captchaStepStartSnapshot = await snapshotPage(captchaTarget, signalCounter, page);
10309
10616
  const sitekeyProbeExpr = `(() => {
10310
10617
  const el = document.querySelector("[data-sitekey]");
10311
10618
  if (!el) return { siteKey: null, isInvisible: false };
@@ -10447,11 +10754,13 @@ async function executeStepWithHealing(params) {
10447
10754
  signalCounter,
10448
10755
  page,
10449
10756
  step,
10757
+ stepStartSnapshot: captchaStepStartSnapshot ?? undefined,
10450
10758
  isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10451
10759
  step,
10452
10760
  submitStep,
10453
10761
  isFinalStep,
10454
10762
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10763
+ elementSubmitShaped: !stepDeclaredNonSubmit,
10455
10764
  }),
10456
10765
  });
10457
10766
  }
@@ -10593,6 +10902,66 @@ async function executeStepWithHealing(params) {
10593
10902
  // legitimate-transition check (below) scans captures landed during THIS
10594
10903
  // step's processing — earlier-step transitions don't count.
10595
10904
  const stepStartMetaLength = recentCaptureMeta.length;
10905
+ let firstAttemptPre = null;
10906
+ let stepStartSnapshot = isStepSubmitShaped({
10907
+ step,
10908
+ submitStep,
10909
+ isFinalStep,
10910
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10911
+ })
10912
+ ? await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page)
10913
+ : null;
10914
+ // A click on a disabled control can still grow the DOM (a validation
10915
+ // re-render), which would read as a step-start advance — so the last
10916
+ // resolved click's disabled veto must also gate the baseline credit.
10917
+ let lastClickBlockedByDisabled = false;
10918
+ // A step whose submit shape is only known from a resolved element got no
10919
+ // step-level baseline; the first attempt's pre-click snapshot is the
10920
+ // step-start page, so every later verdict site judges against it.
10921
+ const adoptElementStepStartBaseline = () => {
10922
+ if (stepStartSnapshot || !firstAttemptPre)
10923
+ return;
10924
+ stepStartSnapshot = firstAttemptPre;
10925
+ onElementStepStartBaseline?.(firstAttemptPre);
10926
+ };
10927
+ // One derivation for every in-cascade verdict site, so the same click is
10928
+ // judged under the same strictness class and baseline whichever site sees it.
10929
+ const deriveMidFlowJudgement = (elementSubmitShaped, pre) => {
10930
+ const midFlowSubmit = isMidFlowSubmitShapedStep({
10931
+ step,
10932
+ submitStep,
10933
+ isFinalStep,
10934
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10935
+ elementSubmitShaped,
10936
+ });
10937
+ if (elementSubmitShaped)
10938
+ adoptElementStepStartBaseline();
10939
+ return { midFlowSubmit, advanceBaseline: stepStartSnapshot ?? pre };
10940
+ };
10941
+ const creditStepStartAdvance = async (exit) => {
10942
+ const genuineFinal = isGenuineFinalSubmit({
10943
+ isFinalStep,
10944
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10945
+ });
10946
+ if (!stepStartSnapshot || genuineFinal || lastClickBlockedByDisabled)
10947
+ return false;
10948
+ const { post: settled, pageAdvanced } = await readMidFlowPostClickAdvance({
10949
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
10950
+ signalCounter,
10951
+ page,
10952
+ pre: stepStartSnapshot,
10953
+ stepStartSnapshot,
10954
+ midFlowSubmit: true,
10955
+ });
10956
+ if (!pageAdvanced)
10957
+ return false;
10958
+ // A DOM change that came from bouncing to an auth gate is not an advance.
10959
+ if (!(0, phantom_click_1.isPlausibleStepDestination)(step, settled.url, stepStartSnapshot.url))
10960
+ return false;
10961
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} ${exit}: settled page advanced from the step-start baseline; crediting the step`);
10962
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
10963
+ return true;
10964
+ };
10596
10965
  // Page-state probe BEFORE the cascade. When the page doesn't have any
10597
10966
  // candidate matching the step's instruction we either skip cleanly
10598
10967
  // (optional) or escalate straight to replan (required) — far cheaper than
@@ -10683,6 +11052,7 @@ async function executeStepWithHealing(params) {
10683
11052
  submitStep,
10684
11053
  isFinalStep,
10685
11054
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11055
+ elementSubmitShaped: !stepDeclaredNonSubmit,
10686
11056
  }),
10687
11057
  });
10688
11058
  if (fallbackTarget) {
@@ -10691,6 +11061,8 @@ async function executeStepWithHealing(params) {
10691
11061
  return "completed";
10692
11062
  }
10693
11063
  }
11064
+ if (await creditStepStartAdvance("probe-absent"))
11065
+ return "completed";
10694
11066
  // Capture diagnostics + write a failure dump BEFORE throwing so the
10695
11067
  // global replan path's `readFailureDumpEvidence` can populate the
10696
11068
  // prompt's CURRENTLY VISIBLE / UNFOCUSED OBSERVE / PAGE BODY HTML
@@ -11021,6 +11393,21 @@ async function executeStepWithHealing(params) {
11021
11393
  };
11022
11394
  let phantomClickAfterAttempt1 = false;
11023
11395
  let attempt1UnreachableViaLightDom = false;
11396
+ // Attempt 1 resolved no candidate at all and was skipped before any verdict,
11397
+ // so it never reached the "unresolved" escalation a judged attempt 1 takes.
11398
+ let attempt1FastSkipped = false;
11399
+ // The deep submit-control locator takes attempt 2 when attempt 1 judged its
11400
+ // target unreachable, and attempt 3 when attempt 1 was fast-skipped and the
11401
+ // observe-act retry in attempt 2 did not verify, so a submit-shaped step with
11402
+ // no resolvable light-DOM target is never left without it.
11403
+ const deepSubmitLocatorSlot = (attemptNumber) => isStepSubmitShaped({
11404
+ step,
11405
+ submitStep,
11406
+ isFinalStep,
11407
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11408
+ }) &&
11409
+ ((attemptNumber === 2 && attempt1UnreachableViaLightDom) ||
11410
+ (attemptNumber === 3 && attempt1FastSkipped));
11024
11411
  // Last candidate list the deep-submit-locator itself ranked this step, if
11025
11412
  // any — reused by the cascade-exhausted diagnostic snapshot below so it
11026
11413
  // doesn't re-run buildRankSubmitCandidatesExpr (and inflate the rank-call
@@ -11035,23 +11422,17 @@ async function executeStepWithHealing(params) {
11035
11422
  // Skip to the next iteration so the cascade reaches its higher-value
11036
11423
  // techniques faster.
11037
11424
  if (attempt > 1) {
11038
- const wouldBeTechnique = attempt === 2
11039
- ? attempt1UnreachableViaLightDom &&
11040
- isStepSubmitShaped({
11041
- step,
11042
- submitStep,
11043
- isFinalStep,
11044
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11045
- })
11046
- ? "deep-submit-locator"
11047
- : phantomClickAfterAttempt1
11425
+ const wouldBeTechnique = deepSubmitLocatorSlot(attempt)
11426
+ ? "deep-submit-locator"
11427
+ : attempt === 2
11428
+ ? phantomClickAfterAttempt1
11048
11429
  ? "trusted-click-retry"
11049
11430
  : "observe-act"
11050
- : attempt === 3
11051
- ? "structured-click"
11052
- : attempt === 4
11053
- ? "observe-act-exclude"
11054
- : "llm-rephrase";
11431
+ : attempt === 3
11432
+ ? "structured-click"
11433
+ : attempt === 4
11434
+ ? "observe-act-exclude"
11435
+ : "llm-rephrase";
11055
11436
  const decision = shouldSkipTechnique({
11056
11437
  technique: wouldBeTechnique,
11057
11438
  priorAttempts: attempts.map((a) => ({
@@ -11081,7 +11462,15 @@ async function executeStepWithHealing(params) {
11081
11462
  // A click from an earlier attempt that errored after opening a tab leaves the
11082
11463
  // run on the stale page; pick the tab up before measuring anything.
11083
11464
  await adoptOpenedPage(0);
11465
+ // A prior attempt's click may have advanced the page after its own verdict;
11466
+ // judge against the step-start baseline before `pre` absorbs the advance.
11467
+ if (attempt > 1 && (await creditStepStartAdvance(`attempt-${attempt}-entry`)))
11468
+ return "completed";
11084
11469
  const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page, captureSelectionState);
11470
+ // Recorded before any fast-skip `continue`, so an attempt-1 act that threw
11471
+ // after its click landed still leaves the step-start page behind.
11472
+ firstAttemptPre ??= pre;
11473
+ await armPageQuiescence(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
11085
11474
  // Snapshot the meta-tail length so the final-step pattern gate can scope
11086
11475
  // its URL scan to captures added DURING this attempt (not historical
11087
11476
  // tail from earlier steps).
@@ -11116,6 +11505,15 @@ async function executeStepWithHealing(params) {
11116
11505
  // whether this attempt's signal should come from the network/URL pair or
11117
11506
  // from DOM re-read. Captured here so both branches of the cascade write to it.
11118
11507
  let resolvedAction = null;
11508
+ // Submit shape of the control observe resolved BEFORE the act clicked it.
11509
+ // The act performs the click itself, so a page that swaps at once has
11510
+ // already removed the control by the time the post-act read runs; this is
11511
+ // the only read that cannot be blinded by the transition it caused.
11512
+ const submitShapeObservedBeforeClick = new Map();
11513
+ // Every submit-shaped control on the page before the act, so the shape of
11514
+ // the control the act clicked is known even when observe resolved nothing
11515
+ // or a different node than the act did.
11516
+ const submitShapedControlsBeforeClick = new Set();
11119
11517
  // Stagehand resolves a natural-language act target internally, so for a
11120
11518
  // click-intent instruction resolve it with observe first and centre it;
11121
11519
  // a sticky bar otherwise swallows Stagehand's own click. Throws when the
@@ -11124,10 +11522,17 @@ async function executeStepWithHealing(params) {
11124
11522
  const preCentreNaturalLanguageClick = async (instruction) => {
11125
11523
  if (!(0, stagehand_guard_1.hasClickIntent)(instruction))
11126
11524
  return false;
11525
+ if (!stepDeclaredNonSubmit) {
11526
+ for (const xpath of await readSubmitShapedControlXpaths(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))) {
11527
+ submitShapedControlsBeforeClick.add(xpath);
11528
+ }
11529
+ }
11127
11530
  const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
11128
11531
  const first = observed[0];
11129
11532
  if (!(0, stagehand_guard_1.isTrustedClickMethod)(first?.method) || !first?.selector)
11130
11533
  return true;
11534
+ submitShapeObservedBeforeClick.set(first.selector, !stepDeclaredNonSubmit &&
11535
+ (await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector)));
11131
11536
  const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector);
11132
11537
  if (failure)
11133
11538
  throw new Error(failure);
@@ -11225,14 +11630,7 @@ async function executeStepWithHealing(params) {
11225
11630
  }
11226
11631
  }
11227
11632
  }
11228
- else if (attempt === 2 &&
11229
- attempt1UnreachableViaLightDom &&
11230
- isStepSubmitShaped({
11231
- step,
11232
- submitStep,
11233
- isFinalStep,
11234
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11235
- })) {
11633
+ else if (deepSubmitLocatorSlot(attempt)) {
11236
11634
  // Deep submit-control locator: attempt 1 either phantom-clicked
11237
11635
  // (Stagehand reported success but pre/post showed zero effect) or
11238
11636
  // came back "unresolved" (act() found zero candidates at all), so the
@@ -11308,36 +11706,26 @@ async function executeStepWithHealing(params) {
11308
11706
  const runnerUp = ranked[1];
11309
11707
  if (runnerUp) {
11310
11708
  const midTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
11311
- const midPost = await (0, click_settle_1.settleAndSnapshot)({
11709
+ // Ranked candidates are submit-shaped by construction, so the
11710
+ // resolved element's shape is the step's declared-non-submit gate.
11711
+ const { midFlowSubmit: deepMidFlowSubmit, advanceBaseline: deepStepBaseline } = deriveMidFlowJudgement(!stepDeclaredNonSubmit, pre);
11712
+ const { post: midPost, advanceBaseline: deepAdvanceBaseline } = await readMidFlowPostClickAdvance({
11713
+ target: midTarget,
11714
+ signalCounter,
11312
11715
  page,
11313
- counter: signalCounter,
11314
11716
  pre,
11315
- submitShaped: true,
11316
- snapshot: (withPage) => snapshotPage(midTarget, signalCounter, withPage ? page : undefined),
11317
- readQuiescence: async () => midTarget.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR),
11717
+ stepStartSnapshot: deepStepBaseline,
11718
+ midFlowSubmit: deepMidFlowSubmit,
11318
11719
  });
11319
- const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
11320
- actResultSuccess: true,
11720
+ // The deep-submit-locator runs only on submit escalation, so a
11721
+ // stray selection flip must not mask a top-pick phantom and skip
11722
+ // the runner-up retry.
11723
+ const topVerdict = classifySubmitShapedClick({
11724
+ step,
11321
11725
  pre,
11322
11726
  post: midPost,
11323
- // The deep-submit-locator runs only on submit escalation, so a
11324
- // stray selection flip must not mask a top-pick phantom and skip
11325
- // the runner-up retry.
11326
- isSubmitShapedStep: true,
11327
- isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
11328
- step,
11329
- submitStep,
11330
- isFinalStep,
11331
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11332
- }),
11333
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url, isMidFlowSubmitShapedStep({
11334
- step,
11335
- submitStep,
11336
- isFinalStep,
11337
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11338
- })
11339
- ? pre.url
11340
- : undefined),
11727
+ midFlowSubmit: deepMidFlowSubmit,
11728
+ advanceBaseline: deepAdvanceBaseline,
11341
11729
  });
11342
11730
  if (topVerdict === "phantom") {
11343
11731
  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})`);
@@ -11725,6 +12113,14 @@ async function executeStepWithHealing(params) {
11725
12113
  };
11726
12114
  }
11727
12115
  }
12116
+ if (overriddenTarget.method === "fill" &&
12117
+ (await isDateSegmentAtSelector(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), overriddenTarget.selector))) {
12118
+ record.errorMessage = `refused fill on a date segment: "${target.description.slice(0, 60)}" (a lone-segment write cannot compose a date)`;
12119
+ attempts.push(record);
12120
+ failureReasons.push(record.errorMessage);
12121
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
12122
+ continue;
12123
+ }
11728
12124
  params.abortSignal?.throwIfAborted();
11729
12125
  // Stagehand's internal Locator.click() does not scroll past sticky
11730
12126
  // bars, so centre + hit-test the observed control first; an
@@ -12064,39 +12460,67 @@ async function executeStepWithHealing(params) {
12064
12460
  ? `attempt 1 fast-skipped (no resolved action): ${record.errorMessage}`
12065
12461
  : "attempt 1 fast-skipped (no resolved action; Stagehand returned no candidates)";
12066
12462
  failureReasons.push(reason);
12463
+ attempt1FastSkipped = true;
12067
12464
  continue;
12068
12465
  }
12069
12466
  // A fill/select/type action cannot open a tab, so skip the discovery wait for it.
12070
12467
  await adoptOpenedPage(resolvedAction && STATE_CLASS_METHODS.has(resolvedAction.method ?? "") ? 0 : undefined);
12468
+ const isClick = resolvedAction !== null && resolvedAction.method === "click";
12469
+ // Attribute-based submit-shape read of the resolved click target itself —
12470
+ // independent of the flow-authored `submitStep` flag. Read before the
12471
+ // settle: a view swap removes the clicked control, so a post-swap read
12472
+ // would blind the mid-flow exemption on exactly the case it exists for.
12473
+ // See resolvedClickTargetIsSubmitShaped's doc comment.
12474
+ // A successful act that listed no actions still clicked what observe
12475
+ // resolved first, so that control's pre-click shape stands in for it.
12476
+ const resolvedElementIsSubmitShaped = isClick && !stepDeclaredNonSubmit && resolvedAction?.selector
12477
+ ? submitShapeObservedBeforeClick.get(resolvedAction.selector) === true ||
12478
+ submitShapedControlsBeforeClick.has(canonicalAbsoluteXpath(resolvedAction.selector) ?? "") ||
12479
+ (await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector))
12480
+ : resolvedAction === null &&
12481
+ record.actResultSuccess === true &&
12482
+ !stepDeclaredNonSubmit &&
12483
+ submitShapeObservedBeforeClick.values().next().value === true;
12484
+ if (resolvedElementIsSubmitShaped)
12485
+ adoptElementStepStartBaseline();
12486
+ const advanceBaseline = stepStartSnapshot ?? pre;
12487
+ // Computed once: gates every credit signal that takes no post-click URL
12488
+ // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
12489
+ const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
12490
+ step,
12491
+ submitStep,
12492
+ isFinalStep,
12493
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12494
+ elementSubmitShaped: resolvedElementIsSubmitShaped,
12495
+ });
12071
12496
  await page.waitForTimeout(STEP_PAUSE_MS);
12072
12497
  // A successful act that reported no actions still ran a click-class action;
12073
12498
  // judging it before its round-trip lands would cascade onto the advanced page.
12074
12499
  const mayTriggerRoundTrip = resolvedAction === null
12075
12500
  ? record.actResultSuccess === true
12076
12501
  : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
12077
- if (mayTriggerRoundTrip) {
12078
- const settleTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
12079
- await settleWithPageQuiescence(page, settleTarget, signalCounter, pre.networkCount, isSettleWaitStep, pre.visibleTextSignature);
12080
- }
12081
- const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
12502
+ // Re-settle is not gated on submit shape: an unflagged step or a no-action
12503
+ // act can still advance a page whose body re-renders late. Credit stays
12504
+ // gated on stepIsMidFlowSubmit, so the final step keeps its strictness.
12505
+ const { post } = await readMidFlowPostClickAdvance({
12506
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12507
+ signalCounter,
12508
+ page,
12509
+ pre,
12510
+ stepStartSnapshot: advanceBaseline,
12511
+ midFlowSubmit: stepIsMidFlowSubmit,
12512
+ mayTriggerRoundTrip,
12513
+ waitForMutations: isSettleWaitStep || resolvedElementIsSubmitShaped,
12514
+ });
12082
12515
  record.post = post;
12083
12516
  if (resolvedAction) {
12084
12517
  record.resolvedMethod = resolvedAction.method ?? null;
12085
12518
  record.resolvedArguments = (resolvedAction.arguments ?? []).map(String);
12086
12519
  }
12087
12520
  const networkFired = post.networkCount > pre.networkCount;
12088
- // Computed once: gates every credit signal that takes no post-click URL
12089
- // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
12090
- const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
12091
- step,
12092
- submitStep,
12093
- isFinalStep,
12094
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12095
- });
12096
12521
  const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, stepIsMidFlowSubmit ? pre.url : undefined);
12097
12522
  const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url) && destinationPlausible;
12098
12523
  const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
12099
- const isClick = resolvedAction !== null && resolvedAction.method === "click";
12100
12524
  // State-class actions (fill/check/etc.) never move the network counter or URL,
12101
12525
  // so the legacy heuristic false-negatived every form fill. Re-read DOM state
12102
12526
  // for those; keep the navigation signal authoritative for clicks/links —
@@ -12193,12 +12617,6 @@ async function executeStepWithHealing(params) {
12193
12617
  const clickedElementStillPresent = isClick && resolvedAction?.selector
12194
12618
  ? await resolvedClickTargetStillPresent(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
12195
12619
  : true;
12196
- // Attribute-based submit-shape read of the resolved click target itself —
12197
- // independent of the flow-authored `submitStep` flag. See
12198
- // resolvedClickTargetIsSubmitShaped's doc comment.
12199
- const resolvedElementIsSubmitShaped = isClick && !stepDeclaredNonSubmit && resolvedAction?.selector
12200
- ? await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
12201
- : false;
12202
12620
  // Client-side view-swap gate: credit a click that produces substantial
12203
12621
  // DOM growth (≥5KB) with zero network when it's NOT a submit/final step
12204
12622
  // and NOT an advance-pattern step. Fixes the top-window site "Manual Application"
@@ -12215,23 +12633,42 @@ async function executeStepWithHealing(params) {
12215
12633
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12216
12634
  elementSubmitShaped: resolvedElementIsSubmitShaped,
12217
12635
  });
12636
+ // A submit-worded last slot without submit semantics is mid-flow
12637
+ // (isGenuineFinalSubmit), so the sibling predicates below must not hold it
12638
+ // to genuine-final strictness while the verdict gates already credit it on
12639
+ // a settled advance.
12640
+ const primarySubmitShaped = isStepSubmitShaped({
12641
+ step,
12642
+ submitStep,
12643
+ isFinalStep,
12644
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12645
+ }) &&
12646
+ (submitStep ||
12647
+ isGenuineFinalSubmit({ isFinalStep, flowHasSubmitSemantics: flowHasSubmitSemanticsFlag }) ||
12648
+ !isFinalStep);
12218
12649
  // An element-inferred mid-flow submit joins the exemption the step-derived
12219
12650
  // one already got: a click that never left its URL did not bounce to a gate.
12220
- const midFlowDestinationPlausible = destinationPlausible ||
12221
- (midFlowSubmit && (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, pre.url));
12651
+ const midFlowDestinationPlausibleForPost = midFlowDestinationPlausible({
12652
+ step,
12653
+ pre,
12654
+ post,
12655
+ midFlowSubmit,
12656
+ advanceBaseline,
12657
+ destinationPlausible,
12658
+ });
12222
12659
  const clickViewSwapVerified = isClickViewSwapVerified({
12223
12660
  resolvedAction,
12224
12661
  submitStep,
12225
12662
  isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
12226
12663
  networkDelta: post.networkCount - pre.networkCount,
12227
12664
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
12228
- textChanged: post.visibleTextSignature !== pre.visibleTextSignature,
12665
+ textChanged: (0, phantom_click_1.hasVisibleTextChanged)(advanceBaseline, post),
12229
12666
  invalidMarkerDelta: postInvalidMarkerCount - preInvalidMarkerCount,
12230
12667
  clickedElementStillPresent,
12231
12668
  resolvedElementIsSubmitShaped,
12232
- destinationPlausible: midFlowDestinationPlausible,
12669
+ destinationPlausible: midFlowDestinationPlausibleForPost,
12233
12670
  midFlowSubmit,
12234
- pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
12671
+ pageAdvanced: (0, phantom_click_1.hasSettledPageAdvanced)(advanceBaseline, post),
12235
12672
  actSucceeded: record.actResultSuccess === true,
12236
12673
  });
12237
12674
  if (clickViewSwapVerified === false &&
@@ -12265,12 +12702,7 @@ async function executeStepWithHealing(params) {
12265
12702
  // submitStep, inferred final-step, or an attribute-detected submit
12266
12703
  // control) may not ride this signal alone — only when the submit-judge
12267
12704
  // gate (requireSubmitEndpoint) will re-litigate the credit below.
12268
- const formValueWeakSignalAllowed = !(isStepSubmitShaped({
12269
- step,
12270
- submitStep,
12271
- isFinalStep,
12272
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12273
- }) || resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
12705
+ const formValueWeakSignalAllowed = !(primarySubmitShaped || resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
12274
12706
  const formValueVerified = isStateClass &&
12275
12707
  formValueWeakSignalAllowed &&
12276
12708
  destinationPlausible &&
@@ -12390,7 +12822,8 @@ async function executeStepWithHealing(params) {
12390
12822
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12391
12823
  }),
12392
12824
  isMidFlowSubmitStep: midFlowSubmit,
12393
- destinationPlausible: midFlowDestinationPlausible,
12825
+ advanceBaseline,
12826
+ destinationPlausible: midFlowDestinationPlausibleForPost,
12394
12827
  });
12395
12828
  // An `"effective"` verdict driven purely by the page-wide byte-delta
12396
12829
  // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
@@ -12411,12 +12844,7 @@ async function executeStepWithHealing(params) {
12411
12844
  // respects the SAME DOM-only-advance veto as the block above: a wizard
12412
12845
  // "Next" step on a pattern-configured site whose only signal is a field
12413
12846
  // toggle must stay unverified, not get waved through by the verdict.
12414
- const domEffectiveVerdict = !isStepSubmitShaped({
12415
- step,
12416
- submitStep,
12417
- isFinalStep,
12418
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12419
- }) && domVerifiedForStep;
12847
+ const domEffectiveVerdict = !primarySubmitShaped && domVerifiedForStep;
12420
12848
  let verified = networkIsRealAdvance ||
12421
12849
  urlChanged ||
12422
12850
  domVerifiedForStep ||
@@ -12499,11 +12927,17 @@ async function executeStepWithHealing(params) {
12499
12927
  captureFn,
12500
12928
  step,
12501
12929
  isFinalStep,
12930
+ submitStep,
12931
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12932
+ elementSubmitShaped: resolvedElementIsSubmitShaped,
12502
12933
  page,
12503
12934
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12504
12935
  signalCounter,
12505
12936
  preNetworkCount: pre.networkCount,
12506
- preVisibleTextSignature: pre.visibleTextSignature,
12937
+ preVisibleTextSignature: advanceBaseline.visibleTextSignature,
12938
+ preVisibleText: advanceBaseline.visibleText,
12939
+ preVisibleTextDigest: advanceBaseline.visibleTextDigest,
12940
+ settledPostVisibleText: post.visibleText,
12507
12941
  submitInput: {
12508
12942
  pageUrl: post.url,
12509
12943
  pageTitle,
@@ -12522,14 +12956,14 @@ async function executeStepWithHealing(params) {
12522
12956
  // A mid-flow transition is credited on the page's own demonstrable
12523
12957
  // advance (visible text swapped); the judge's final-submit-flavoured
12524
12958
  // view of a stale title or missing network must not undo it.
12525
- const midFlowAdvanceCredited = (midFlowSubmit && clickViewSwapVerified) ||
12526
- isNullVerdictAdvanceCredited({
12527
- midFlowSubmit,
12528
- judgeUnavailable: judgeVerdict === null,
12529
- networkAdvanced: networkIsRealAdvance,
12530
- pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
12531
- invalidMarkerCount,
12532
- });
12959
+ const midFlowAdvanceCredited = isMidFlowAdvanceCredited({
12960
+ midFlowSubmit,
12961
+ signalAdvanced: clickViewSwapVerified,
12962
+ judgeUnavailable: judgeVerdict === null,
12963
+ networkAdvanced: networkIsRealAdvance,
12964
+ pageAdvanced: (0, phantom_click_1.hasSettledPageAdvanced)(advanceBaseline, post),
12965
+ invalidMarkerCount,
12966
+ });
12533
12967
  if (midFlowAdvanceCredited && judgeVerdict?.verified !== true) {
12534
12968
  logger.info(`mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
12535
12969
  }
@@ -12565,7 +12999,7 @@ async function executeStepWithHealing(params) {
12565
12999
  // Any-4xx fallback. The judge said failed; surface any captured 4xx
12566
13000
  // body's field-level error JSON for the rephrase prompt downstream
12567
13001
  // — same role as before the migration.
12568
- const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], (0, recon_shared_1.resolveReconRunDir)().graphqlDir, "any-4xx");
13002
+ const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], (0, runtime_capture_store_1.defaultCapturesDir)(), "any-4xx");
12569
13003
  if (fallbackEvidence.length > 0) {
12570
13004
  failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
12571
13005
  }
@@ -12740,20 +13174,22 @@ async function executeStepWithHealing(params) {
12740
13174
  probeResult.checked === true &&
12741
13175
  (probeResult.ariaManaged === true || !ancestorStillInvalid);
12742
13176
  await page.waitForTimeout(STEP_PAUSE_MS);
12743
- await settleWithPageQuiescence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, pre.networkCount, isSettleWaitStep, pre.visibleTextSignature);
12744
- const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
12745
- const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12746
- const retryMidFlowSubmit = isMidFlowSubmitShapedStep({
12747
- step,
12748
- submitStep,
12749
- isFinalStep,
12750
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12751
- elementSubmitShaped: retryResolvedElementIsSubmitShaped,
13177
+ const { midFlowSubmit: retryMidFlowSubmit, advanceBaseline: retryAdvanceBaseline } = deriveMidFlowJudgement(retryResolvedElementIsSubmitShaped, pre);
13178
+ const { post: retryPost, pageAdvanced: retryPageAdvanced } = await readMidFlowPostClickAdvance({
13179
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
13180
+ signalCounter,
13181
+ page,
13182
+ pre,
13183
+ stepStartSnapshot: retryAdvanceBaseline,
13184
+ midFlowSubmit: retryMidFlowSubmit,
13185
+ waitForMutations: isSettleWaitStep || retryResolvedElementIsSubmitShaped,
12752
13186
  });
13187
+ const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12753
13188
  const retryUrlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, retryPost.url) &&
12754
13189
  (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, retryMidFlowSubmit ? pre.url : undefined);
12755
- const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
12756
- const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
13190
+ const retryDomBaseline = retryMidFlowSubmit ? retryAdvanceBaseline : pre;
13191
+ const retryHtmlDelta = retryPost.bodyHtmlLength - retryDomBaseline.bodyHtmlLength;
13192
+ const retryTextChanged = (0, phantom_click_1.hasVisibleTextChanged)(retryDomBaseline, retryPost);
12757
13193
  const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
12758
13194
  // Element-scoped selection read-back for the n+16 fallback — the same
12759
13195
  // authoritative signal the primary verifier uses via `verifyDomEffect`,
@@ -12823,6 +13259,8 @@ async function executeStepWithHealing(params) {
12823
13259
  .evaluate(disabledExpr)
12824
13260
  .catch(() => false);
12825
13261
  })());
13262
+ if (probeResult.kind === "click")
13263
+ lastClickBlockedByDisabled = clickBlockedByDisabled;
12826
13264
  const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
12827
13265
  const clickBlockedByInvalid = clickWasDomOnly &&
12828
13266
  (await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0)) >
@@ -12848,10 +13286,14 @@ async function executeStepWithHealing(params) {
12848
13286
  const retryDestinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
12849
13287
  // Only the verdict sees the pre.url exemption, and only for a demonstrable
12850
13288
  // page advance: bare network/DOM signals stay gated on the landed URL.
12851
- const retryMidFlowDestinationPlausible = retryDestinationPlausible ||
12852
- (retryMidFlowSubmit &&
12853
- (0, phantom_click_1.hasPageAdvanced)(pre, retryPost) &&
12854
- (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, pre.url));
13289
+ const retryMidFlowDestinationPlausible = midFlowDestinationPlausible({
13290
+ step,
13291
+ pre,
13292
+ post: retryPost,
13293
+ midFlowSubmit: retryMidFlowSubmit,
13294
+ advanceBaseline: retryAdvanceBaseline,
13295
+ destinationPlausible: retryDestinationPlausible,
13296
+ });
12855
13297
  const retryNetworkIsRealAdvance = retryNetworkFired &&
12856
13298
  retryDestinationPlausible &&
12857
13299
  (await waitForTransitionBody({
@@ -12959,6 +13401,7 @@ async function executeStepWithHealing(params) {
12959
13401
  elementStateChanged: retrySelectionStateChanged,
12960
13402
  isSubmitShapedStep: retryMidFlowSubmit || retrySubmitShaped,
12961
13403
  isMidFlowSubmitStep: retryMidFlowSubmit,
13404
+ advanceBaseline: retryAdvanceBaseline,
12962
13405
  destinationPlausible: retryMidFlowDestinationPlausible,
12963
13406
  });
12964
13407
  // Destination-plausibility audit of every remaining disjunct in
@@ -12995,6 +13438,10 @@ async function executeStepWithHealing(params) {
12995
13438
  (n16ClickDelivered &&
12996
13439
  (!retrySubmitShaped || retryMidFlowSubmit) &&
12997
13440
  retryVerdict === "effective") ||
13441
+ (n16ClickDelivered &&
13442
+ retryMidFlowSubmit &&
13443
+ retryPageAdvanced &&
13444
+ retryMidFlowDestinationPlausible) ||
12998
13445
  (n16ClickDelivered &&
12999
13446
  weakDomSignalsAllowed &&
13000
13447
  retryDestinationPlausible &&
@@ -13092,11 +13539,17 @@ async function executeStepWithHealing(params) {
13092
13539
  captureFn,
13093
13540
  step,
13094
13541
  isFinalStep,
13542
+ submitStep,
13543
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13544
+ elementSubmitShaped: retryResolvedElementIsSubmitShaped,
13095
13545
  page,
13096
13546
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
13097
13547
  signalCounter,
13098
13548
  preNetworkCount: pre.networkCount,
13099
- preVisibleTextSignature: pre.visibleTextSignature,
13549
+ preVisibleTextSignature: retryAdvanceBaseline.visibleTextSignature,
13550
+ preVisibleText: retryAdvanceBaseline.visibleText,
13551
+ preVisibleTextDigest: retryAdvanceBaseline.visibleTextDigest,
13552
+ settledPostVisibleText: retryPost.visibleText,
13100
13553
  submitInput: {
13101
13554
  pageUrl: retryPost.url,
13102
13555
  pageTitle,
@@ -13112,16 +13565,14 @@ async function executeStepWithHealing(params) {
13112
13565
  submittedStateSelectors: matchedSubmittedSelectors,
13113
13566
  },
13114
13567
  });
13115
- const retryMidFlowAdvanceCredited = (retryMidFlowSubmit &&
13116
- retryVerdict === "effective" &&
13117
- (0, phantom_click_1.hasPageAdvanced)(pre, retryPost)) ||
13118
- isNullVerdictAdvanceCredited({
13119
- midFlowSubmit: retryMidFlowSubmit,
13120
- judgeUnavailable: judgeVerdict === null,
13121
- networkAdvanced: retryNetworkFired && retryDestinationPlausible,
13122
- pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, retryPost),
13123
- invalidMarkerCount,
13124
- });
13568
+ const retryMidFlowAdvanceCredited = isMidFlowAdvanceCredited({
13569
+ midFlowSubmit: retryMidFlowSubmit,
13570
+ signalAdvanced: retryVerdict === "effective" && retryPageAdvanced,
13571
+ judgeUnavailable: judgeVerdict === null,
13572
+ networkAdvanced: retryNetworkFired && retryDestinationPlausible,
13573
+ pageAdvanced: retryPageAdvanced,
13574
+ invalidMarkerCount,
13575
+ });
13125
13576
  if (retryMidFlowAdvanceCredited && judgeVerdict?.verified !== true) {
13126
13577
  logger.info(`n+16 fallback mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
13127
13578
  }
@@ -13300,12 +13751,7 @@ async function executeStepWithHealing(params) {
13300
13751
  // (triedSelectors), which is empty on an "unresolved" verdict, so a
13301
13752
  // non-submit step with that verdict falls through to the normal
13302
13753
  // ladder instead of escalating.
13303
- const escalationTarget = isStepSubmitShaped({
13304
- step,
13305
- submitStep,
13306
- isFinalStep,
13307
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13308
- })
13754
+ const escalationTarget = primarySubmitShaped
13309
13755
  ? "escalating attempt 2 to deep-submit-locator"
13310
13756
  : phantomClickAfterAttempt1
13311
13757
  ? "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)"
@@ -13317,17 +13763,16 @@ async function executeStepWithHealing(params) {
13317
13763
  // Treat the canonical submit click as "final" for this predicate
13318
13764
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
13319
13765
  // above for the same gate-widening rationale.
13320
- isFinalStep: isStepSubmitShaped({
13321
- step,
13322
- submitStep,
13323
- isFinalStep,
13324
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13325
- }),
13766
+ isFinalStep: primarySubmitShaped,
13326
13767
  requireSubmitEndpoint,
13327
13768
  resolvedMethod: record.resolvedMethod,
13328
13769
  effectSignals,
13329
13770
  preSubmitInvalidCount,
13330
13771
  postAttemptInvalidCount,
13772
+ midFlowAdvanced: !isGenuineFinalSubmit({
13773
+ isFinalStep,
13774
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13775
+ }) && (0, phantom_click_1.hasSettledPageAdvanced)(advanceBaseline, post),
13331
13776
  });
13332
13777
  if (earlyExit) {
13333
13778
  const exitReason = `submit-revealed-invalid: click surfaced ${postAttemptInvalidCount - preSubmitInvalidCount} new ng-invalid container(s) (was ${preSubmitInvalidCount}, now ${postAttemptInvalidCount}); attempts 2-${MAX_STEP_ATTEMPTS} cannot heal a form that needs answers — routing to replan`;
@@ -13389,6 +13834,7 @@ async function executeStepWithHealing(params) {
13389
13834
  submitStep,
13390
13835
  isFinalStep,
13391
13836
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13837
+ elementSubmitShaped: !stepDeclaredNonSubmit,
13392
13838
  }),
13393
13839
  });
13394
13840
  if (fallbackTarget) {
@@ -13397,6 +13843,8 @@ async function executeStepWithHealing(params) {
13397
13843
  return "completed";
13398
13844
  }
13399
13845
  }
13846
+ if (await creditStepStartAdvance("cascade-exhausted"))
13847
+ return "completed";
13400
13848
  const cascadeExhaustObservedFinal = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
13401
13849
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
13402
13850
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -13492,6 +13940,9 @@ async function executeNavigateStep(params) {
13492
13940
  * than let the flow report phantom success.
13493
13941
  */
13494
13942
  async function runHealingFlow(deps) {
13943
+ return (0, runtime_capture_store_1.withRuntimeCaptureStore)((store) => runHealingFlowScoped(deps, store));
13944
+ }
13945
+ async function runHealingFlowScoped(deps, store) {
13495
13946
  const { stagehand, page, steps, logger, anthropic, rephraseModel, uploadFixture, maxFlowMs } = deps;
13496
13947
  for (const secret of deps.secrets ?? [])
13497
13948
  (0, pii_redaction_1.registerSecretValue)(secret);
@@ -13521,6 +13972,7 @@ async function runHealingFlow(deps) {
13521
13972
  recentCaptures,
13522
13973
  recentCaptureMeta,
13523
13974
  getCurrentPhase: () => "flow",
13975
+ onCapture: (capture, filename) => (0, runtime_capture_store_1.recordRuntimeCapture)(store, filename, capture),
13524
13976
  getCurrentPageOrigin: () => {
13525
13977
  try {
13526
13978
  return new URL(activePage.url()).origin;