@enricai/barnacle 1.12.82 → 1.14.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 (53) 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 +3 -2
  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/logging.d.ts +14 -4
  13. package/dist/lib/logging.d.ts.map +1 -1
  14. package/dist/lib/logging.js +34 -25
  15. package/dist/lib/logging.js.map +1 -1
  16. package/dist/lib/random.d.ts +7 -3
  17. package/dist/lib/random.d.ts.map +1 -1
  18. package/dist/lib/random.js +27 -4
  19. package/dist/lib/random.js.map +1 -1
  20. package/dist/scraper/click-settle.d.ts +16 -7
  21. package/dist/scraper/click-settle.d.ts.map +1 -1
  22. package/dist/scraper/click-settle.js +31 -24
  23. package/dist/scraper/click-settle.js.map +1 -1
  24. package/dist/scraper/deep-locator-actuate.d.ts.map +1 -1
  25. package/dist/scraper/deep-locator-actuate.js +17 -1
  26. package/dist/scraper/deep-locator-actuate.js.map +1 -1
  27. package/dist/scraper/deep-locator-fake.js +1 -1
  28. package/dist/scraper/deep-locator-fake.js.map +1 -1
  29. package/dist/scraper/flow-runner.d.ts +139 -8
  30. package/dist/scraper/flow-runner.d.ts.map +1 -1
  31. package/dist/scraper/flow-runner.js +710 -316
  32. package/dist/scraper/flow-runner.js.map +1 -1
  33. package/dist/scraper/phantom-click.d.ts +45 -1
  34. package/dist/scraper/phantom-click.d.ts.map +1 -1
  35. package/dist/scraper/phantom-click.js +114 -10
  36. package/dist/scraper/phantom-click.js.map +1 -1
  37. package/dist/scraper/runtime-capture-store.d.ts +38 -0
  38. package/dist/scraper/runtime-capture-store.d.ts.map +1 -0
  39. package/dist/scraper/runtime-capture-store.js +103 -0
  40. package/dist/scraper/runtime-capture-store.js.map +1 -0
  41. package/dist/scraper/segmented-date-spinbutton.d.ts +13 -0
  42. package/dist/scraper/segmented-date-spinbutton.d.ts.map +1 -0
  43. package/dist/scraper/segmented-date-spinbutton.js +71 -0
  44. package/dist/scraper/segmented-date-spinbutton.js.map +1 -0
  45. package/dist/scripts/recon-browser.d.ts +0 -8
  46. package/dist/scripts/recon-browser.d.ts.map +1 -1
  47. package/dist/scripts/recon-browser.js +102 -50
  48. package/dist/scripts/recon-browser.js.map +1 -1
  49. package/dist/scripts/recon-generate.d.ts +11 -0
  50. package/dist/scripts/recon-generate.d.ts.map +1 -1
  51. package/dist/scripts/recon-generate.js +180 -53
  52. package/dist/scripts/recon-generate.js.map +1 -1
  53. 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,6 +76,7 @@ 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;
@@ -92,12 +95,15 @@ exports.parseSegmentedDateValue = parseSegmentedDateValue;
92
95
  exports.resolveSegmentedDateGroup = resolveSegmentedDateGroup;
93
96
  exports.parseSegmentedPartStep = parseSegmentedPartStep;
94
97
  exports.trySegmentedDateSpinbuttonPrimitive = trySegmentedDateSpinbuttonPrimitive;
98
+ exports.runSegmentedDateSpinbuttonPrimitive = runSegmentedDateSpinbuttonPrimitive;
95
99
  exports.parseRadioStep = parseRadioStep;
96
100
  exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
97
101
  exports.parseCheckStep = parseCheckStep;
98
102
  exports.parseCheckStateStep = parseCheckStateStep;
99
103
  exports.pollEnumerate = pollEnumerate;
100
104
  exports.waitForTransitionBody = waitForTransitionBody;
105
+ exports.settleAndSnapshotPostClick = settleAndSnapshotPostClick;
106
+ exports.readMidFlowPostClickAdvance = readMidFlowPostClickAdvance;
101
107
  exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
102
108
  exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
103
109
  exports.trySelectPrimitive = trySelectPrimitive;
@@ -158,6 +164,8 @@ const errors_2 = require("./errors");
158
164
  const frame_target_1 = require("./frame-target");
159
165
  const phantom_click_1 = require("./phantom-click");
160
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");
161
169
  const session_teardown_1 = require("./session-teardown");
162
170
  const spa_readiness_1 = require("./spa-readiness");
163
171
  const stagehand_guard_1 = require("./stagehand-guard");
@@ -166,7 +174,6 @@ const target_resolution_diagnostic_1 = require("./target-resolution-diagnostic")
166
174
  const trusted_click_1 = require("./trusted-click");
167
175
  const upload_affordance_expr_1 = require("./upload-affordance-expr");
168
176
  const watchdog_1 = require("./watchdog");
169
- const recon_shared_1 = require("../scripts/recon-shared");
170
177
  const client_1 = require("../testmail/client");
171
178
  var spa_readiness_2 = require("./spa-readiness");
172
179
  Object.defineProperty(exports, "waitForSpaReady", { enumerable: true, get: function () { return spa_readiness_2.waitForSpaReady; } });
@@ -1109,7 +1116,9 @@ function prepareFailureDumpBody(raw) {
1109
1116
  function ariaCheckedReadExpr(elVar) {
1110
1117
  return `${elVar}.getAttribute("aria-checked")`;
1111
1118
  }
1112
- 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 }; })()`;
1113
1122
  /** Caps mirroring {@link DOM_SNAPSHOT_EXPR}'s truncation so a pathological page can't bloat the failure bundle. */
1114
1123
  const FIELD_VALUES_AT_FAILURE_MAX_CONTROLS = 200;
1115
1124
  const FIELD_VALUES_AT_FAILURE_MAX_VALUE_LENGTH = 500;
@@ -1175,7 +1184,15 @@ async function readPageQuiescence(target) {
1175
1184
  : idle;
1176
1185
  }
1177
1186
  /**
1178
- * Reads only the visible-text signature settle polls on. Going through
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
1179
1196
  * {@link snapshotPage} also read the frame URL, which rejects once a submit
1180
1197
  * detaches the OOPIF and would abort the settle mid-poll; a failed read here
1181
1198
  * degrades to the empty signature like the snapshot's own text read does.
@@ -1184,35 +1201,16 @@ async function readSettleTextSignature(target) {
1184
1201
  const result = await Promise.resolve(target.evaluate(DOM_SNAPSHOT_EXPR)).catch(() => null);
1185
1202
  if (result === null || typeof result !== "object")
1186
1203
  return "";
1187
- const { text } = result;
1188
- return typeof text === "string" ? text : "";
1204
+ const { text, digest } = result;
1205
+ return (0, click_settle_1.settleTextKey)(typeof text === "string" ? text : "", typeof digest === "string" ? digest : undefined);
1189
1206
  }
1190
1207
  /**
1191
1208
  * Settles after a click using the target's own text signature and in-page
1192
1209
  * quiescence reader, so every post-click site decides from the same settle
1193
1210
  * signal instead of hand-building the readers.
1194
1211
  */
1195
- async function settleWithPageQuiescence(page, target, signalCounter, preNetworkCount, submitShaped, preVisibleTextSignature, advanced) {
1196
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, preNetworkCount, submitShaped, () => readSettleTextSignature(target), preVisibleTextSignature, () => readPageQuiescence(target), advanced);
1197
- }
1198
- /**
1199
- * Builds the "has the page finished advancing" test a mid-flow submit settles
1200
- * against. It is the body-swap half of the {@link hasPageAdvanced} the phantom
1201
- * classifier credits with, so a header that flipped before its body re-rendered
1202
- * keeps settle polling while the classifier still credits whatever is left once
1203
- * the wait is spent.
1204
- * Undefined for any other step, which keeps the plain quiet-window settle.
1205
- */
1206
- function pageAdvancedSince(preVisibleTextSignature, midFlowSubmit) {
1207
- if (!midFlowSubmit)
1208
- return undefined;
1209
- const pre = {
1210
- networkCount: 0,
1211
- url: "",
1212
- bodyHtmlLength: 0,
1213
- visibleTextSignature: preVisibleTextSignature,
1214
- };
1215
- return (visibleTextSignature) => (0, phantom_click_1.hasPageBodySwapped)(pre, { ...pre, visibleTextSignature });
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));
1216
1214
  }
1217
1215
  /**
1218
1216
  * Captures the pre/post signal triple the submit-verify cascade diffs.
@@ -1231,6 +1229,8 @@ function pageAdvancedSince(preVisibleTextSignature, midFlowSubmit) {
1231
1229
  async function snapshotPage(target, signalCounter, page, captureSelectionState = false) {
1232
1230
  let bodyHtmlLength = 0;
1233
1231
  let visibleTextSignature = "";
1232
+ let visibleText = "";
1233
+ let visibleTextDigest = "";
1234
1234
  let formValueSignature = "";
1235
1235
  let selectionStateSignature = "";
1236
1236
  try {
@@ -1243,6 +1243,10 @@ async function snapshotPage(target, signalCounter, page, captureSelectionState =
1243
1243
  typeof result.text === "string") {
1244
1244
  bodyHtmlLength = result.html;
1245
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 : "";
1246
1250
  const rawValues = result.values;
1247
1251
  formValueSignature = typeof rawValues === "string" ? rawValues : "";
1248
1252
  const rawState = result.state;
@@ -1285,6 +1289,8 @@ async function snapshotPage(target, signalCounter, page, captureSelectionState =
1285
1289
  url,
1286
1290
  bodyHtmlLength,
1287
1291
  visibleTextSignature,
1292
+ visibleText,
1293
+ visibleTextDigest: visibleTextDigest === "" ? undefined : visibleTextDigest,
1288
1294
  formValueSignature,
1289
1295
  selectionStateSignature,
1290
1296
  selectionStateByXpath,
@@ -1584,12 +1590,20 @@ function isStepSettleWaitShaped(params) {
1584
1590
  return isStepSubmitShaped(params) || isAdvanceStep(params.step);
1585
1591
  }
1586
1592
  /**
1587
- * 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
1588
1602
  * (e.g. account creation) that can be credited on a demonstrable page advance
1589
1603
  * instead of needing the final submit's network/URL proof.
1590
1604
  */
1591
1605
  function isMidFlowSubmitShapedStep(params) {
1592
- if (params.isFinalStep)
1606
+ if (isGenuineFinalSubmit(params))
1593
1607
  return false;
1594
1608
  return isStepSubmitShaped(params) || params.elementSubmitShaped === true;
1595
1609
  }
@@ -1719,15 +1733,7 @@ function latestCaptureIndex(recentCaptures) {
1719
1733
  * so callers scan in capture order. Returns [] when the dir is unreadable.
1720
1734
  */
1721
1735
  function capturesAfterIndex(preIdx, capturesDir) {
1722
- let entries;
1723
- try {
1724
- entries = (0, node_fs_1.readdirSync)(capturesDir);
1725
- }
1726
- catch {
1727
- return [];
1728
- }
1729
- return entries
1730
- .filter((f) => f.endsWith(".json") && !f.endsWith(".decoded.json"))
1736
+ return (0, runtime_capture_store_1.listCaptureFiles)(capturesDir)
1731
1737
  .map((f) => ({ f, idx: parseCaptureIndex(f) }))
1732
1738
  .filter((e) => e.idx !== null && e.idx > preIdx)
1733
1739
  .sort((a, b) => a.idx - b.idx)
@@ -1758,7 +1764,7 @@ function windowHasTransitionBody(params) {
1758
1764
  const { preIdx, advanceTransitionBodyPattern } = params;
1759
1765
  if (!advanceTransitionBodyPattern)
1760
1766
  return false;
1761
- const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
1767
+ const capturesDir = params.capturesDir ?? (0, runtime_capture_store_1.defaultCapturesDir)();
1762
1768
  let rx;
1763
1769
  try {
1764
1770
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -1768,8 +1774,9 @@ function windowHasTransitionBody(params) {
1768
1774
  }
1769
1775
  for (const filename of capturesAfterIndex(preIdx, capturesDir)) {
1770
1776
  try {
1771
- const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, filename), "utf8");
1772
- const capture = JSON.parse(raw);
1777
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
1778
+ if (!capture)
1779
+ continue;
1773
1780
  const body = typeof capture.requestPostData === "string"
1774
1781
  ? capture.requestPostData
1775
1782
  : JSON.stringify(capture.requestPostData ?? "");
@@ -1800,7 +1807,7 @@ function windowHasAdvanceTransition(params) {
1800
1807
  const { preIdx, advanceTransitionBodyPattern } = params;
1801
1808
  if (!advanceTransitionBodyPattern)
1802
1809
  return false;
1803
- const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
1810
+ const capturesDir = params.capturesDir ?? (0, runtime_capture_store_1.defaultCapturesDir)();
1804
1811
  let rx;
1805
1812
  try {
1806
1813
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -1810,8 +1817,9 @@ function windowHasAdvanceTransition(params) {
1810
1817
  }
1811
1818
  for (const filename of capturesAfterIndex(preIdx, capturesDir)) {
1812
1819
  try {
1813
- const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, filename), "utf8");
1814
- const capture = JSON.parse(raw);
1820
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
1821
+ if (!capture)
1822
+ continue;
1815
1823
  const body = typeof capture.requestPostData === "string"
1816
1824
  ? capture.requestPostData
1817
1825
  : JSON.stringify(capture.requestPostData ?? "");
@@ -1881,13 +1889,29 @@ function isDomOnlyAdvanceVerified(params) {
1881
1889
  * rejection, so strong structural click signals (a real network advance or a
1882
1890
  * demonstrable page advance) credit the step instead of failing it and
1883
1891
  * re-clicking at the same position. Final steps and weak-signal cases return
1884
- * 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.
1885
1895
  */
1886
1896
  function isNullVerdictAdvanceCredited(params) {
1887
- if (!params.midFlowSubmit || !params.judgeUnavailable || params.invalidMarkerCount > 0) {
1897
+ if (!params.midFlowSubmit || !params.judgeUnavailable)
1888
1898
  return false;
1889
- }
1890
- 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);
1891
1915
  }
1892
1916
  /**
1893
1917
  * Client-side view-swap verification gate. Credits a click that produces
@@ -1980,7 +2004,7 @@ function isNullVerdictAdvanceCredited(params) {
1980
2004
  *
1981
2005
  * **Mid-flow submit:** `midFlowSubmit` (see {@link isMidFlowSubmitShapedStep})
1982
2006
  * lets a submit-shaped, non-final step be credited when `pageAdvanced` (the
1983
- * shared {@link hasPageAdvanced} judgement of the visible text; defaults to
2007
+ * shared {@link hasSettledPageAdvanced} judgement of the visible text; defaults to
1984
2008
  * `textChanged` when omitted) holds — byte delta is not required, since a
1985
2009
  * wizard swap can keep the HTML size — with the same invalid-marker and
1986
2010
  * destination vetoes. The clicked control being gone is NOT a veto here: a
@@ -2003,7 +2027,6 @@ function isClickViewSwapVerified(params) {
2003
2027
  midFlowActRan &&
2004
2028
  destinationPlausible !== false &&
2005
2029
  !isAdvanceWithPattern &&
2006
- invalidMarkerDelta <= 0 &&
2007
2030
  textChanged &&
2008
2031
  pageAdvanced) {
2009
2032
  return true;
@@ -2121,7 +2144,7 @@ async function countNgInvalidContainers(target) {
2121
2144
  function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLength) {
2122
2145
  const bytesDelta = post.bodyHtmlLength - pre.bodyHtmlLength;
2123
2146
  const networkDelta = post.networkCount - pre.networkCount;
2124
- const textChanged = post.visibleTextSignature !== pre.visibleTextSignature;
2147
+ const textChanged = (0, phantom_click_1.hasVisibleTextChanged)(pre, post);
2125
2148
  const valueChanged = post.formValueSignature !== pre.formValueSignature;
2126
2149
  const selectionStateChanged = post.selectionStateSignature !== pre.selectionStateSignature;
2127
2150
  const windowCaptures = recentCaptureMeta.slice(preMetaLength);
@@ -2262,9 +2285,11 @@ function shouldSkipTechnique(params) {
2262
2285
  * navigations, partial DOM reflows, etc.).
2263
2286
  */
2264
2287
  function isSubmitRevealedInvalid(params) {
2265
- const { isFinalStep, requireSubmitEndpoint, resolvedMethod, effectSignals, preSubmitInvalidCount, postAttemptInvalidCount, } = params;
2288
+ const { isFinalStep, requireSubmitEndpoint, resolvedMethod, effectSignals, preSubmitInvalidCount, postAttemptInvalidCount, midFlowAdvanced = false, } = params;
2266
2289
  if (!isFinalStep)
2267
2290
  return false;
2291
+ if (midFlowAdvanced)
2292
+ return false;
2268
2293
  if (!requireSubmitEndpoint)
2269
2294
  return false;
2270
2295
  if (resolvedMethod !== "click")
@@ -2955,32 +2980,36 @@ const STEP_REJUDGE_COMPARE_CHARS = 1200;
2955
2980
  */
2956
2981
  async function judgeStepOutcome(params) {
2957
2982
  const { client, captureFn, step, isFinalStep, target } = params;
2958
- if (isFinalStep) {
2983
+ const genuineFinal = isGenuineFinalSubmit({
2984
+ isFinalStep,
2985
+ flowHasSubmitSemantics: params.flowHasSubmitSemantics,
2986
+ });
2987
+ if (genuineFinal) {
2959
2988
  return (0, verify_submit_1.verifySubmitWithLLM)({
2960
2989
  client,
2961
2990
  input: {
2962
- ...(await buildSubmitJudgeStepContext({ step, isFinalStep, target })),
2991
+ ...(await buildSubmitJudgeStepContext({ step, isFinalStep: genuineFinal, target })),
2963
2992
  ...params.submitInput,
2964
2993
  },
2965
2994
  captureFn,
2966
2995
  });
2967
2996
  }
2968
- await settleWithPageQuiescence(params.page, target, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({
2969
- step,
2970
- submitStep: params.submitStep,
2971
- isFinalStep,
2972
- flowHasSubmitSemantics: params.flowHasSubmitSemantics,
2973
- }) || params.elementSubmitShaped === true, params.preVisibleTextSignature, pageAdvancedSince(params.preVisibleTextSignature, isMidFlowSubmitShapedStep({
2974
- step,
2975
- submitStep: params.submitStep,
2976
- isFinalStep,
2977
- flowHasSubmitSemantics: params.flowHasSubmitSemantics,
2978
- elementSubmitShaped: params.elementSubmitShaped,
2979
- })));
2980
- const colon = params.preVisibleTextSignature.indexOf(":");
2981
- 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))
2982
3010
  .replace(/\s+/g, " ")
2983
3011
  .trim();
3012
+ const beforeText = params.preVisibleText ? params.preVisibleText : signatureHead;
2984
3013
  const judgeText = (afterText) => (0, verify_step_advanced_1.verifyStepAdvancedWithLLM)({
2985
3014
  client,
2986
3015
  input: {
@@ -2992,7 +3021,7 @@ async function judgeStepOutcome(params) {
2992
3021
  captureFn,
2993
3022
  });
2994
3023
  const rejudgeIfTextMoved = async (first, judgedText) => {
2995
- if (first === null || first.advanced)
3024
+ if (first === null || first.advanced || hasSettledRead)
2996
3025
  return first;
2997
3026
  await params.page.waitForTimeout(STEP_REJUDGE_DELAY_MS);
2998
3027
  const laterText = await probeVisibleTextForJudge(target);
@@ -3002,7 +3031,9 @@ async function judgeStepOutcome(params) {
3002
3031
  }
3003
3032
  return (await judgeText(laterText)) ?? first;
3004
3033
  };
3005
- const afterText = await probeVisibleTextForJudge(target);
3034
+ const afterText = hasSettledRead
3035
+ ? (params.settledPostVisibleText ?? "")
3036
+ : await probeVisibleTextForJudge(target);
3006
3037
  const firstVerdict = await judgeText(afterText);
3007
3038
  const verdict = await rejudgeIfTextMoved(firstVerdict, afterText);
3008
3039
  if (verdict === null)
@@ -3116,7 +3147,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
3116
3147
  * deterministic hostname equality. Empty list / "any-4xx" mode disables
3117
3148
  * the host filter and returns any 4xx in the window.
3118
3149
  */
3119
- ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
3150
+ ownBackendHostnames, capturesDir = (0, runtime_capture_store_1.defaultCapturesDir)(), mode = "strict") {
3120
3151
  if (recentCaptureFilenames.length === 0)
3121
3152
  return "";
3122
3153
  if (mode === "strict" && ownBackendHostnames.length === 0)
@@ -3128,9 +3159,9 @@ ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().grap
3128
3159
  continue;
3129
3160
  seen.add(filename);
3130
3161
  try {
3131
- const path = (0, node_path_1.join)(capturesDir, filename);
3132
- const raw = (0, node_fs_1.readFileSync)(path, "utf8");
3133
- const capture = JSON.parse(raw);
3162
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
3163
+ if (!capture)
3164
+ continue;
3134
3165
  if (typeof capture.url !== "string")
3135
3166
  continue;
3136
3167
  if (mode === "strict") {
@@ -3207,7 +3238,7 @@ ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().grap
3207
3238
  * Returns a numbered evidence list. Empty string when no GA collect
3208
3239
  * captures are present. Advisory — never load-bearing.
3209
3240
  */
3210
- function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
3241
+ function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, runtime_capture_store_1.defaultCapturesDir)()) {
3211
3242
  if (recentCaptureFilenames.length === 0)
3212
3243
  return "";
3213
3244
  const records = [];
@@ -3217,9 +3248,9 @@ function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_
3217
3248
  continue;
3218
3249
  seen.add(filename);
3219
3250
  try {
3220
- const path = (0, node_path_1.join)(capturesDir, filename);
3221
- const raw = (0, node_fs_1.readFileSync)(path, "utf8");
3222
- const capture = JSON.parse(raw);
3251
+ const capture = (0, runtime_capture_store_1.readCaptureFile)(capturesDir, filename);
3252
+ if (!capture)
3253
+ continue;
3223
3254
  if (typeof capture.url !== "string")
3224
3255
  continue;
3225
3256
  let url;
@@ -3591,6 +3622,25 @@ async function fillTextDatepickerInput(target, selector, value) {
3591
3622
  return { filled: false, postValue: "", strategy: "none" };
3592
3623
  }
3593
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
+ }
3594
3644
  /**
3595
3645
  * Read back an element's value after a fill action and compare to the
3596
3646
  * expected value. Catches silent-value-rejection cases that the verifier's
@@ -3628,7 +3678,7 @@ async function verifyFillReadback(target, selector, expectedValue) {
3628
3678
  if (!el) return null;
3629
3679
  const tag = el.tagName ? el.tagName.toLowerCase() : "";
3630
3680
  let actual = "";
3631
- ${SEGMENT_OWNER_JS}
3681
+ ${segmented_date_spinbutton_1.SEGMENT_OWNER_JS}
3632
3682
  if (isDateSegment(el)) {
3633
3683
  return { outcome: "segment", postValue: el.value || el.getAttribute("aria-valuenow") || "", tag };
3634
3684
  }
@@ -4265,6 +4315,47 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
4265
4315
  return false;
4266
4316
  }
4267
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
+ }
4268
4359
  /**
4269
4360
  * True when the primary (Stagehand-recorded) xpath is unresolvable and only
4270
4361
  * `xpathTailForRetarget`'s loose tag+same-tag-sibling-position re-anchor
@@ -5468,7 +5559,7 @@ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5468
5559
  // A group under another heading is not the field the step names, however
5469
5560
  // well its label matches: the step's own words must find the section.
5470
5561
  // A section that only repeats the group's own label is no heading at all
5471
- // (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.
5472
5563
  const namesOtherSection = (group) => {
5473
5564
  const section = segmentedDateTokens(group.section ?? "");
5474
5565
  const label = segmentedDateTokens(group.label ?? "");
@@ -5529,7 +5620,7 @@ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5529
5620
  if (leaders.length > 1) {
5530
5621
  // A nested group is one wrapper's slice of a larger date widget (e.g. the
5531
5622
  // Year half of "From MM/YYYY"), so it only ties with a standalone group of
5532
- // 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
5533
5624
  // "Work Experience 1 From" and "Education 1 From" with no heading the
5534
5625
  // enumerator can see. The standalone group is the field the step names.
5535
5626
  const standalone = leaders.filter((i) => !groups[i]?.nested);
@@ -5542,71 +5633,13 @@ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5542
5633
  const only = groups.flatMap((g, i) => (compatible(g) && !g.nested ? [i] : []));
5543
5634
  return only.length === 1 ? (only[0] ?? null) : null;
5544
5635
  }
5545
- /**
5546
- * In-page helpers shared by the date-segment enumeration and the fill
5547
- * readback so "is this spinbutton one segment of a multi-segment widget" has a
5548
- * single definition; defines `spins`, `ownerOf` and `isDateSegment`.
5549
- */
5550
- const SEGMENT_OWNER_JS = `
5551
- const hiddenAttr = (node) => node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true";
5552
- const styleOf = (node) => {
5553
- try { return typeof getComputedStyle === "function" ? getComputedStyle(node) : window.getComputedStyle(node); } catch { return null; }
5554
- };
5555
- const isHidden = (el) => {
5556
- for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
5557
- if (hiddenAttr(node)) return true;
5558
- const style = styleOf(node);
5559
- if (style && (style.display === "none" || style.visibility === "hidden")) return true;
5560
- }
5561
- return false;
5562
- };
5563
- const spins = Array.from(document.querySelectorAll("input[role='spinbutton']")).filter((el) => !isHidden(el));
5564
- const norm = (s) => (s || "").replace(/\\s+/g, " ").trim();
5565
- const idText = (ids) => (ids || "").split(/\\s+/).map((id) => {
5566
- const ref = id ? document.getElementById(id) : null;
5567
- return ref ? norm(ref.textContent) : "";
5568
- }).join(" ");
5569
- const legendOf = (node) => {
5570
- const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
5571
- return legend ? norm(legend.textContent) : "";
5572
- };
5573
- const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
5574
- const tiedToSpin = (l) => l.querySelector("input,[role='spinbutton']") || (l.getAttribute("for") && spins.some((sp) => sp.id === l.getAttribute("for")));
5575
- const hasCaption = (node) => namedBy(node) !== "" || Array.from(node.querySelectorAll("label,legend")).some((l) => !tiedToSpin(l));
5576
- const captionOwnerOf = (el) => {
5577
- const explicit = el.closest("fieldset,[role='group']");
5578
- if (explicit) return explicit;
5579
- for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5580
- if (hasCaption(node)) return node;
5581
- }
5582
- return null;
5583
- };
5584
- const bareSpins = spins.filter((sp) => !captionOwnerOf(sp));
5585
- const hasOtherControl = (node) => Array.from(node.querySelectorAll("input,select,textarea")).some((c) => !spins.includes(c) && c.type !== "hidden");
5586
- // Segments that each sit in their own uncaptioned wrapper share no
5587
- // fieldset/caption, so the nearest ancestor holding several bare spinbuttons
5588
- // (and no other form control) is the widget they belong to.
5589
- const bareOwnerOf = (el) => {
5590
- for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5591
- if (hasOtherControl(node)) break;
5592
- if (bareSpins.filter((sp) => node.contains(sp)).length > 1) return node;
5593
- }
5594
- return el.parentElement;
5595
- };
5596
- const ownerOf = (el) => captionOwnerOf(el) || bareOwnerOf(el);
5597
- const isDateSegment = (el) => {
5598
- if (!el.getAttribute || el.getAttribute("role") !== "spinbutton" || !spins.includes(el)) return false;
5599
- const box = ownerOf(el);
5600
- return spins.filter((sp) => ownerOf(sp) === box).length > 1;
5601
- };
5602
- `;
5603
5636
  const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
5604
5637
  /** Exported so tests can run the real DOM enumeration against markup. */
5605
5638
  exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5606
5639
  const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
5607
5640
  const stale = document.querySelectorAll("[" + attr + "]");
5608
5641
  stale.forEach((el) => el.removeAttribute(attr));
5609
- ${SEGMENT_OWNER_JS}
5642
+ ${segmented_date_spinbutton_1.SEGMENT_OWNER_JS}
5610
5643
  if (spins.length === 0) return { groups: [] };
5611
5644
  const headingSibling = (node) => {
5612
5645
  let prevSib = node.previousElementSibling;
@@ -5660,15 +5693,21 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5660
5693
  const prior = seg.getAttribute(attr);
5661
5694
  seg.setAttribute(attr, prior ? prior + " " + token : token);
5662
5695
  };
5663
- const addGroup = (segs, box, nested) => {
5696
+ const addGroup = (segs, box, nested, outer) => {
5664
5697
  const gi = groups.length;
5665
5698
  segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5666
5699
  let inner = null;
5667
- for (let node = segs[0].parentElement; node && node !== box && box.contains(node) && !inner; node = node.parentElement) {
5668
- if (segs.every((seg) => node.contains(seg)) && hasCaption(node)) inner = node;
5669
- }
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;
5670
5709
  const labelBox = inner || box;
5671
- 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(" ");
5672
5711
  const section = sectionOf(labelBox, Boolean(inner));
5673
5712
  groups.push({
5674
5713
  segmentMaxes: segs.map((seg, si) => maxOf(seg, si, segs.length)),
@@ -5691,7 +5730,7 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5691
5730
  }
5692
5731
  const wrap = seg.closest("label");
5693
5732
  if (wrap) parts.push(norm(wrap.textContent));
5694
- return keyOf(parts.filter(Boolean).join(" "));
5733
+ return keyOf(Array.from(new Set(parts.filter(Boolean))).join(" "));
5695
5734
  };
5696
5735
  // A container's legend names the whole section; the field a segment belongs
5697
5736
  // to is the caption naming it, or the caption nearest before it with no other
@@ -5705,8 +5744,15 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5705
5744
  if (el === boxLegend) return;
5706
5745
  if (el.matches("label,legend")) {
5707
5746
  const target = el.getAttribute("for");
5708
- if (isHidden(el) || el.querySelector("input,select,textarea,[role='spinbutton']") || (target && box.querySelector("[id='" + target + "']"))) return;
5709
- 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;
5710
5756
  return;
5711
5757
  }
5712
5758
  if (!own.has(el)) {
@@ -5729,10 +5775,8 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5729
5775
  }
5730
5776
  // A shared fieldset can hold several independent dates (From and To year
5731
5777
  // spinbuttons side by side); each wrapper is then its own candidate group.
5732
- if (byParent.size > 1) {
5733
- byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
5734
- return;
5735
- }
5778
+ const split = byParent.size > 1;
5779
+ if (split) byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent, box]));
5736
5780
  const keys = fieldKeys(segs, box);
5737
5781
  const runs = [];
5738
5782
  segs.forEach((seg, si) => {
@@ -5740,9 +5784,13 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5740
5784
  if (last && last.key === keys[si]) last.segs.push(seg);
5741
5785
  else runs.push({ key: keys[si], segs: [seg] });
5742
5786
  });
5743
- 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 }));
5744
5792
  });
5745
- nestedCandidates.forEach(([inner, parent]) => addGroup(inner, parent, true));
5793
+ nestedCandidates.forEach(([inner, parent, outer]) => addGroup(inner, parent, true, outer));
5746
5794
  captionRuns.forEach(({ run, box, outerIndex, whole }) => {
5747
5795
  const outer = groups[outerIndex];
5748
5796
  const same = (a, b) => a.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() === b.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
@@ -5771,21 +5819,38 @@ const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
5771
5819
  *
5772
5820
  * Why this exists: such widgets keep each segment's value in component state
5773
5821
  * driven by `keydown`/`beforeinput`; `fill`/`insertText` bypasses that and
5774
- * garbles the segments. Returns a marker id on commit, or null on no
5775
- * spinbuttons, an unresolvable group, or any segment mismatch so the caller
5776
- * 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`.
5777
5825
  */
5778
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) {
5779
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;
5780
5845
  const whole = parseSegmentedDateValue(instruction);
5781
5846
  const part = parseSegmentedPartStep(instruction);
5782
5847
  if (!whole && !part)
5783
- return null;
5848
+ return notApplicable;
5784
5849
  const sendCDP = frameCdpSender(page, target);
5785
5850
  try {
5786
5851
  const enumerated = (await target.evaluate(exports.SEGMENTED_DATE_ENUMERATE_EXPR));
5787
5852
  if (!enumerated?.groups?.length)
5788
- return null;
5853
+ return notApplicable;
5789
5854
  const wholeGi = whole ? resolveSegmentedDateGroup(instruction, enumerated.groups) : null;
5790
5855
  const partStep = wholeGi === null ? part : null;
5791
5856
  const value = (wholeGi !== null ? whole : part);
@@ -5801,10 +5866,18 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5801
5866
  ? pickSegmentedDateGroup(instruction, enumerated.groups, partStep, partFits)
5802
5867
  : null);
5803
5868
  if (gi === null) {
5804
- logger.info("segmented date primitive: no unambiguous group; falling through");
5805
5869
  await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5806
- 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;
5807
5879
  }
5880
+ groupChosen = true;
5808
5881
  const maxes = enumerated.groups[gi]?.segmentMaxes ?? [];
5809
5882
  const widths = maxes.map((max) => String(max).length);
5810
5883
  const onlySegment = partStep ? partOf(enumerated.groups[gi]) : null;
@@ -5901,15 +5974,16 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5901
5974
  actual = await readSegments();
5902
5975
  await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5903
5976
  if (!matches(actual)) {
5904
- logger.info(`segmented date primitive: readback ${maskDateDigits(JSON.stringify(actual))} != ${maskDateDigits(JSON.stringify(expected))}; falling through`);
5905
- return null;
5977
+ return refuse(`readback ${maskDateDigits(JSON.stringify(actual))} != ${maskDateDigits(JSON.stringify(expected))}`);
5906
5978
  }
5907
5979
  logger.info(`segmented date primitive: committed segments ${maskDateDigits(JSON.stringify(actual))}`);
5908
- return `segmented-date-${gi}`;
5980
+ return { kind: "committed", targetId: `segmented-date-${gi}` };
5909
5981
  }
5910
5982
  catch (err) {
5983
+ if (groupChosen)
5984
+ return refuse(`delivery threw: ${(0, errors_1.toErrorMessage)(err)}`);
5911
5985
  logger.warn(`segmented date primitive: threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5912
- return null;
5986
+ return notApplicable;
5913
5987
  }
5914
5988
  }
5915
5989
  /**
@@ -6275,15 +6349,103 @@ function isNavigatingEvaluateRejection(error) {
6275
6349
  message.includes("no frame for given id found") ||
6276
6350
  GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES.has(message.trim()));
6277
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
+ }
6278
6424
  /**
6279
6425
  * Settles after a click and returns the post-click snapshot, so the settle
6280
6426
  * wait and the phantom classifier decide from the same reader and baseline.
6281
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.
6282
6442
  */
6283
- async function settleAndSnapshotPostClick(target, signalCounter, page, pre, midFlowSubmit = false) {
6284
- if (page)
6285
- await settleWithPageQuiescence(page, target, signalCounter, pre.networkCount, true, pre.visibleTextSignature, pageAdvancedSince(pre.visibleTextSignature, midFlowSubmit));
6286
- 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) };
6287
6449
  }
6288
6450
  /**
6289
6451
  * Shared in-page discovery of the widget's callback, used identically by
@@ -6511,6 +6673,38 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
6511
6673
  await target.evaluate(dispatchChangeExpr).catch(() => undefined);
6512
6674
  return { injected, hasForm, callbackDiscovered };
6513
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
+ }
6514
6708
  /**
6515
6709
  * Explicit submit fallback for {@link injectCaptchaTokenAndSubmit} callers:
6516
6710
  * used only when the caller's own transition poll observed no advance after
@@ -6582,6 +6776,8 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6582
6776
  const pre = verification
6583
6777
  ? await snapshotPage(target, verification.signalCounter, verification.page)
6584
6778
  : null;
6779
+ if (pre)
6780
+ await armPageQuiescence(target);
6585
6781
  const clickResult = await target
6586
6782
  .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))
6587
6783
  .catch((err) => {
@@ -6592,14 +6788,30 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6592
6788
  if (clickResult.clicked) {
6593
6789
  if (!pre || !verification)
6594
6790
  return true;
6595
- const post = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, pre, verification.isMidFlowSubmitStep);
6596
- const verdict = (0, phantom_click_1.classifyPhantomClick)({
6597
- 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,
6598
6811
  pre,
6599
6812
  post,
6600
- isSubmitShapedStep: true,
6601
- isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6602
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url, verification.isMidFlowSubmitStep ? pre.url : undefined),
6813
+ midFlowSubmit,
6814
+ advanceBaseline,
6603
6815
  });
6604
6816
  if (verdict !== "phantom")
6605
6817
  return true;
@@ -6613,14 +6825,20 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6613
6825
  throw err;
6614
6826
  });
6615
6827
  if (runnerUpClickResult.clicked) {
6616
- const runnerUpPost = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, post, verification.isMidFlowSubmitStep);
6617
- const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
6618
- 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,
6619
6838
  pre: post,
6620
6839
  post: runnerUpPost,
6621
- isSubmitShapedStep: true,
6622
- isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6623
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url, verification.isMidFlowSubmitStep ? post.url : undefined),
6840
+ midFlowSubmit,
6841
+ advanceBaseline: runnerUpBaseline,
6624
6842
  });
6625
6843
  if (runnerUpVerdict !== "phantom")
6626
6844
  return true;
@@ -9884,6 +10102,7 @@ function primitiveOptionLabel(option, questionLabel, redactValue, fromFill = fal
9884
10102
  */
9885
10103
  async function probeChildFrameSubmitFallback(params) {
9886
10104
  const { page, signalCounter, step, isMidFlowSubmitStep } = params;
10105
+ const midFlowSubmit = isMidFlowSubmitStep === true;
9887
10106
  // `page.mainFrameId`/`page.frames` are read defensively: some call sites
9888
10107
  // (and their test fakes) model a `Page` that never attaches child frames at
9889
10108
  // all and doesn't implement this pair, which is indistinguishable here from
@@ -9933,17 +10152,27 @@ async function probeChildFrameSubmitFallback(params) {
9933
10152
  // biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
9934
10153
  const top = ranked[0];
9935
10154
  const pre = await snapshotPage(target, signalCounter, page);
10155
+ await armPageQuiescence(target);
9936
10156
  const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
9937
10157
  if (!clickResult.clicked)
9938
10158
  continue;
9939
- const post = await settleAndSnapshotPostClick(target, signalCounter, page, pre, isMidFlowSubmitStep);
9940
- const verdict = (0, phantom_click_1.classifyPhantomClick)({
9941
- 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,
9942
10172
  pre,
9943
10173
  post,
9944
- isSubmitShapedStep: true,
9945
- isMidFlowSubmitStep,
9946
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, isMidFlowSubmitStep ? pre.url : undefined),
10174
+ midFlowSubmit,
10175
+ advanceBaseline,
9947
10176
  });
9948
10177
  if (verdict !== "phantom")
9949
10178
  return target;
@@ -9953,14 +10182,20 @@ async function probeChildFrameSubmitFallback(params) {
9953
10182
  const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
9954
10183
  if (!runnerUpClickResult.clicked)
9955
10184
  continue;
9956
- const runnerUpPost = await settleAndSnapshotPostClick(target, signalCounter, page, post, isMidFlowSubmitStep);
9957
- const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
9958
- actResultSuccess: true,
9959
- 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,
9960
10196
  post: runnerUpPost,
9961
- isSubmitShapedStep: true,
9962
- isMidFlowSubmitStep,
9963
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url, isMidFlowSubmitStep ? post.url : undefined),
10197
+ midFlowSubmit,
10198
+ advanceBaseline,
9964
10199
  });
9965
10200
  if (runnerUpVerdict !== "phantom")
9966
10201
  return target;
@@ -9994,7 +10229,14 @@ async function raceAbortSignal(work, signal) {
9994
10229
  }
9995
10230
  }
9996
10231
  async function executeStepWithHealing(params) {
9997
- 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);
9998
10240
  // Mutable: the `emailStep` code-extract path splices the extracted code
9999
10241
  // into this instruction (see the emailStep hook block below) so the
10000
10242
  // pre-existing fill cascade downstream picks it up as the value to type
@@ -10174,7 +10416,11 @@ async function executeStepWithHealing(params) {
10174
10416
  const stepDeclaredNonSubmit = isStepDeclaredNonSubmit({ step, submitStep });
10175
10417
  // Single final-step inference every submit-strictness gate below reads, so a
10176
10418
  // declared non-submit veto cannot be honoured at one site and missed at another.
10177
- 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;
10178
10424
  const requireSubmitEndpoint = (inferredFinalSubmitStep || submitStep) && submitEndpointPattern !== null;
10179
10425
  // Capture the per-element selection baseline (for verifyDomEffect's element-
10180
10426
  // scoped click read-back) ONLY for selection/field-answer click steps — never
@@ -10312,15 +10558,18 @@ async function executeStepWithHealing(params) {
10312
10558
  }
10313
10559
  // Multi-segment date spinbuttons need real key events; the cascade's
10314
10560
  // insertText would garble the segments. Falls through on null.
10315
- const segmentedDateTargetId = await trySegmentedDateSpinbuttonPrimitive({
10561
+ const segmentedDate = await runSegmentedDateSpinbuttonPrimitive({
10316
10562
  page,
10317
10563
  target: selectFrameTarget,
10318
10564
  instruction: step,
10319
10565
  logger,
10320
10566
  });
10321
- 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") {
10322
10571
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by segmented-date primitive`);
10323
- trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: segmentedDateTargetId });
10572
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: segmentedDate.targetId });
10324
10573
  return "completed";
10325
10574
  }
10326
10575
  // On a CATCH-ALL step ("for any remaining … question"), fill every
@@ -10361,6 +10610,9 @@ async function executeStepWithHealing(params) {
10361
10610
  const isSettleWaitStep = isSubmitOrFinalStep || isAdvanceStep(step);
10362
10611
  if (captchaGated && isSubmitOrFinalStep) {
10363
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);
10364
10616
  const sitekeyProbeExpr = `(() => {
10365
10617
  const el = document.querySelector("[data-sitekey]");
10366
10618
  if (!el) return { siteKey: null, isInvisible: false };
@@ -10502,11 +10754,13 @@ async function executeStepWithHealing(params) {
10502
10754
  signalCounter,
10503
10755
  page,
10504
10756
  step,
10757
+ stepStartSnapshot: captchaStepStartSnapshot ?? undefined,
10505
10758
  isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10506
10759
  step,
10507
10760
  submitStep,
10508
10761
  isFinalStep,
10509
10762
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10763
+ elementSubmitShaped: !stepDeclaredNonSubmit,
10510
10764
  }),
10511
10765
  });
10512
10766
  }
@@ -10648,6 +10902,66 @@ async function executeStepWithHealing(params) {
10648
10902
  // legitimate-transition check (below) scans captures landed during THIS
10649
10903
  // step's processing — earlier-step transitions don't count.
10650
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
+ };
10651
10965
  // Page-state probe BEFORE the cascade. When the page doesn't have any
10652
10966
  // candidate matching the step's instruction we either skip cleanly
10653
10967
  // (optional) or escalate straight to replan (required) — far cheaper than
@@ -10738,6 +11052,7 @@ async function executeStepWithHealing(params) {
10738
11052
  submitStep,
10739
11053
  isFinalStep,
10740
11054
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11055
+ elementSubmitShaped: !stepDeclaredNonSubmit,
10741
11056
  }),
10742
11057
  });
10743
11058
  if (fallbackTarget) {
@@ -10746,6 +11061,8 @@ async function executeStepWithHealing(params) {
10746
11061
  return "completed";
10747
11062
  }
10748
11063
  }
11064
+ if (await creditStepStartAdvance("probe-absent"))
11065
+ return "completed";
10749
11066
  // Capture diagnostics + write a failure dump BEFORE throwing so the
10750
11067
  // global replan path's `readFailureDumpEvidence` can populate the
10751
11068
  // prompt's CURRENTLY VISIBLE / UNFOCUSED OBSERVE / PAGE BODY HTML
@@ -11076,6 +11393,21 @@ async function executeStepWithHealing(params) {
11076
11393
  };
11077
11394
  let phantomClickAfterAttempt1 = false;
11078
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));
11079
11411
  // Last candidate list the deep-submit-locator itself ranked this step, if
11080
11412
  // any — reused by the cascade-exhausted diagnostic snapshot below so it
11081
11413
  // doesn't re-run buildRankSubmitCandidatesExpr (and inflate the rank-call
@@ -11090,23 +11422,17 @@ async function executeStepWithHealing(params) {
11090
11422
  // Skip to the next iteration so the cascade reaches its higher-value
11091
11423
  // techniques faster.
11092
11424
  if (attempt > 1) {
11093
- const wouldBeTechnique = attempt === 2
11094
- ? attempt1UnreachableViaLightDom &&
11095
- isStepSubmitShaped({
11096
- step,
11097
- submitStep,
11098
- isFinalStep,
11099
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11100
- })
11101
- ? "deep-submit-locator"
11102
- : phantomClickAfterAttempt1
11425
+ const wouldBeTechnique = deepSubmitLocatorSlot(attempt)
11426
+ ? "deep-submit-locator"
11427
+ : attempt === 2
11428
+ ? phantomClickAfterAttempt1
11103
11429
  ? "trusted-click-retry"
11104
11430
  : "observe-act"
11105
- : attempt === 3
11106
- ? "structured-click"
11107
- : attempt === 4
11108
- ? "observe-act-exclude"
11109
- : "llm-rephrase";
11431
+ : attempt === 3
11432
+ ? "structured-click"
11433
+ : attempt === 4
11434
+ ? "observe-act-exclude"
11435
+ : "llm-rephrase";
11110
11436
  const decision = shouldSkipTechnique({
11111
11437
  technique: wouldBeTechnique,
11112
11438
  priorAttempts: attempts.map((a) => ({
@@ -11136,7 +11462,15 @@ async function executeStepWithHealing(params) {
11136
11462
  // A click from an earlier attempt that errored after opening a tab leaves the
11137
11463
  // run on the stale page; pick the tab up before measuring anything.
11138
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";
11139
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));
11140
11474
  // Snapshot the meta-tail length so the final-step pattern gate can scope
11141
11475
  // its URL scan to captures added DURING this attempt (not historical
11142
11476
  // tail from earlier steps).
@@ -11171,6 +11505,15 @@ async function executeStepWithHealing(params) {
11171
11505
  // whether this attempt's signal should come from the network/URL pair or
11172
11506
  // from DOM re-read. Captured here so both branches of the cascade write to it.
11173
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();
11174
11517
  // Stagehand resolves a natural-language act target internally, so for a
11175
11518
  // click-intent instruction resolve it with observe first and centre it;
11176
11519
  // a sticky bar otherwise swallows Stagehand's own click. Throws when the
@@ -11179,10 +11522,17 @@ async function executeStepWithHealing(params) {
11179
11522
  const preCentreNaturalLanguageClick = async (instruction) => {
11180
11523
  if (!(0, stagehand_guard_1.hasClickIntent)(instruction))
11181
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
+ }
11182
11530
  const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
11183
11531
  const first = observed[0];
11184
11532
  if (!(0, stagehand_guard_1.isTrustedClickMethod)(first?.method) || !first?.selector)
11185
11533
  return true;
11534
+ submitShapeObservedBeforeClick.set(first.selector, !stepDeclaredNonSubmit &&
11535
+ (await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector)));
11186
11536
  const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector);
11187
11537
  if (failure)
11188
11538
  throw new Error(failure);
@@ -11280,14 +11630,7 @@ async function executeStepWithHealing(params) {
11280
11630
  }
11281
11631
  }
11282
11632
  }
11283
- else if (attempt === 2 &&
11284
- attempt1UnreachableViaLightDom &&
11285
- isStepSubmitShaped({
11286
- step,
11287
- submitStep,
11288
- isFinalStep,
11289
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11290
- })) {
11633
+ else if (deepSubmitLocatorSlot(attempt)) {
11291
11634
  // Deep submit-control locator: attempt 1 either phantom-clicked
11292
11635
  // (Stagehand reported success but pre/post showed zero effect) or
11293
11636
  // came back "unresolved" (act() found zero candidates at all), so the
@@ -11363,24 +11706,26 @@ async function executeStepWithHealing(params) {
11363
11706
  const runnerUp = ranked[1];
11364
11707
  if (runnerUp) {
11365
11708
  const midTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
11366
- const deepMidFlowSubmit = isMidFlowSubmitShapedStep({
11367
- step,
11368
- submitStep,
11369
- isFinalStep,
11370
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11371
- elementSubmitShaped: !stepDeclaredNonSubmit,
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,
11715
+ page,
11716
+ pre,
11717
+ stepStartSnapshot: deepStepBaseline,
11718
+ midFlowSubmit: deepMidFlowSubmit,
11372
11719
  });
11373
- const midPost = await settleAndSnapshotPostClick(midTarget, signalCounter, page, pre, deepMidFlowSubmit);
11374
- const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
11375
- 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,
11376
11725
  pre,
11377
11726
  post: midPost,
11378
- // The deep-submit-locator runs only on submit escalation, so a
11379
- // stray selection flip must not mask a top-pick phantom and skip
11380
- // the runner-up retry.
11381
- isSubmitShapedStep: true,
11382
- isMidFlowSubmitStep: deepMidFlowSubmit,
11383
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url, deepMidFlowSubmit ? pre.url : undefined),
11727
+ midFlowSubmit: deepMidFlowSubmit,
11728
+ advanceBaseline: deepAdvanceBaseline,
11384
11729
  });
11385
11730
  if (topVerdict === "phantom") {
11386
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})`);
@@ -11768,6 +12113,14 @@ async function executeStepWithHealing(params) {
11768
12113
  };
11769
12114
  }
11770
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
+ }
11771
12124
  params.abortSignal?.throwIfAborted();
11772
12125
  // Stagehand's internal Locator.click() does not scroll past sticky
11773
12126
  // bars, so centre + hit-test the observed control first; an
@@ -12107,6 +12460,7 @@ async function executeStepWithHealing(params) {
12107
12460
  ? `attempt 1 fast-skipped (no resolved action): ${record.errorMessage}`
12108
12461
  : "attempt 1 fast-skipped (no resolved action; Stagehand returned no candidates)";
12109
12462
  failureReasons.push(reason);
12463
+ attempt1FastSkipped = true;
12110
12464
  continue;
12111
12465
  }
12112
12466
  // A fill/select/type action cannot open a tab, so skip the discovery wait for it.
@@ -12117,9 +12471,19 @@ async function executeStepWithHealing(params) {
12117
12471
  // settle: a view swap removes the clicked control, so a post-swap read
12118
12472
  // would blind the mid-flow exemption on exactly the case it exists for.
12119
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.
12120
12476
  const resolvedElementIsSubmitShaped = isClick && !stepDeclaredNonSubmit && resolvedAction?.selector
12121
- ? await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
12122
- : false;
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;
12123
12487
  // Computed once: gates every credit signal that takes no post-click URL
12124
12488
  // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
12125
12489
  const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
@@ -12135,11 +12499,19 @@ async function executeStepWithHealing(params) {
12135
12499
  const mayTriggerRoundTrip = resolvedAction === null
12136
12500
  ? record.actResultSuccess === true
12137
12501
  : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
12138
- if (mayTriggerRoundTrip) {
12139
- const settleTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
12140
- await settleWithPageQuiescence(page, settleTarget, signalCounter, pre.networkCount, isSettleWaitStep || resolvedElementIsSubmitShaped, pre.visibleTextSignature, pageAdvancedSince(pre.visibleTextSignature, stepIsMidFlowSubmit));
12141
- }
12142
- 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
+ });
12143
12515
  record.post = post;
12144
12516
  if (resolvedAction) {
12145
12517
  record.resolvedMethod = resolvedAction.method ?? null;
@@ -12261,23 +12633,42 @@ async function executeStepWithHealing(params) {
12261
12633
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12262
12634
  elementSubmitShaped: resolvedElementIsSubmitShaped,
12263
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);
12264
12649
  // An element-inferred mid-flow submit joins the exemption the step-derived
12265
12650
  // one already got: a click that never left its URL did not bounce to a gate.
12266
- const midFlowDestinationPlausible = destinationPlausible ||
12267
- (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
+ });
12268
12659
  const clickViewSwapVerified = isClickViewSwapVerified({
12269
12660
  resolvedAction,
12270
12661
  submitStep,
12271
12662
  isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
12272
12663
  networkDelta: post.networkCount - pre.networkCount,
12273
12664
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
12274
- textChanged: post.visibleTextSignature !== pre.visibleTextSignature,
12665
+ textChanged: (0, phantom_click_1.hasVisibleTextChanged)(advanceBaseline, post),
12275
12666
  invalidMarkerDelta: postInvalidMarkerCount - preInvalidMarkerCount,
12276
12667
  clickedElementStillPresent,
12277
12668
  resolvedElementIsSubmitShaped,
12278
- destinationPlausible: midFlowDestinationPlausible,
12669
+ destinationPlausible: midFlowDestinationPlausibleForPost,
12279
12670
  midFlowSubmit,
12280
- pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
12671
+ pageAdvanced: (0, phantom_click_1.hasSettledPageAdvanced)(advanceBaseline, post),
12281
12672
  actSucceeded: record.actResultSuccess === true,
12282
12673
  });
12283
12674
  if (clickViewSwapVerified === false &&
@@ -12311,12 +12702,7 @@ async function executeStepWithHealing(params) {
12311
12702
  // submitStep, inferred final-step, or an attribute-detected submit
12312
12703
  // control) may not ride this signal alone — only when the submit-judge
12313
12704
  // gate (requireSubmitEndpoint) will re-litigate the credit below.
12314
- const formValueWeakSignalAllowed = !(isStepSubmitShaped({
12315
- step,
12316
- submitStep,
12317
- isFinalStep,
12318
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12319
- }) || resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
12705
+ const formValueWeakSignalAllowed = !(primarySubmitShaped || resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
12320
12706
  const formValueVerified = isStateClass &&
12321
12707
  formValueWeakSignalAllowed &&
12322
12708
  destinationPlausible &&
@@ -12436,7 +12822,8 @@ async function executeStepWithHealing(params) {
12436
12822
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12437
12823
  }),
12438
12824
  isMidFlowSubmitStep: midFlowSubmit,
12439
- destinationPlausible: midFlowDestinationPlausible,
12825
+ advanceBaseline,
12826
+ destinationPlausible: midFlowDestinationPlausibleForPost,
12440
12827
  });
12441
12828
  // An `"effective"` verdict driven purely by the page-wide byte-delta
12442
12829
  // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
@@ -12457,12 +12844,7 @@ async function executeStepWithHealing(params) {
12457
12844
  // respects the SAME DOM-only-advance veto as the block above: a wizard
12458
12845
  // "Next" step on a pattern-configured site whose only signal is a field
12459
12846
  // toggle must stay unverified, not get waved through by the verdict.
12460
- const domEffectiveVerdict = !isStepSubmitShaped({
12461
- step,
12462
- submitStep,
12463
- isFinalStep,
12464
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12465
- }) && domVerifiedForStep;
12847
+ const domEffectiveVerdict = !primarySubmitShaped && domVerifiedForStep;
12466
12848
  let verified = networkIsRealAdvance ||
12467
12849
  urlChanged ||
12468
12850
  domVerifiedForStep ||
@@ -12552,7 +12934,10 @@ async function executeStepWithHealing(params) {
12552
12934
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12553
12935
  signalCounter,
12554
12936
  preNetworkCount: pre.networkCount,
12555
- preVisibleTextSignature: pre.visibleTextSignature,
12937
+ preVisibleTextSignature: advanceBaseline.visibleTextSignature,
12938
+ preVisibleText: advanceBaseline.visibleText,
12939
+ preVisibleTextDigest: advanceBaseline.visibleTextDigest,
12940
+ settledPostVisibleText: post.visibleText,
12556
12941
  submitInput: {
12557
12942
  pageUrl: post.url,
12558
12943
  pageTitle,
@@ -12571,14 +12956,14 @@ async function executeStepWithHealing(params) {
12571
12956
  // A mid-flow transition is credited on the page's own demonstrable
12572
12957
  // advance (visible text swapped); the judge's final-submit-flavoured
12573
12958
  // view of a stale title or missing network must not undo it.
12574
- const midFlowAdvanceCredited = (midFlowSubmit && clickViewSwapVerified) ||
12575
- isNullVerdictAdvanceCredited({
12576
- midFlowSubmit,
12577
- judgeUnavailable: judgeVerdict === null,
12578
- networkAdvanced: networkIsRealAdvance,
12579
- pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
12580
- invalidMarkerCount,
12581
- });
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
+ });
12582
12967
  if (midFlowAdvanceCredited && judgeVerdict?.verified !== true) {
12583
12968
  logger.info(`mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
12584
12969
  }
@@ -12614,7 +12999,7 @@ async function executeStepWithHealing(params) {
12614
12999
  // Any-4xx fallback. The judge said failed; surface any captured 4xx
12615
13000
  // body's field-level error JSON for the rephrase prompt downstream
12616
13001
  // — same role as before the migration.
12617
- 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");
12618
13003
  if (fallbackEvidence.length > 0) {
12619
13004
  failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
12620
13005
  }
@@ -12789,26 +13174,22 @@ async function executeStepWithHealing(params) {
12789
13174
  probeResult.checked === true &&
12790
13175
  (probeResult.ariaManaged === true || !ancestorStillInvalid);
12791
13176
  await page.waitForTimeout(STEP_PAUSE_MS);
12792
- await settleWithPageQuiescence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, pre.networkCount, isSettleWaitStep || retryResolvedElementIsSubmitShaped, pre.visibleTextSignature, pageAdvancedSince(pre.visibleTextSignature, isMidFlowSubmitShapedStep({
12793
- step,
12794
- submitStep,
12795
- isFinalStep,
12796
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12797
- elementSubmitShaped: retryResolvedElementIsSubmitShaped,
12798
- })));
12799
- const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
12800
- const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12801
- const retryMidFlowSubmit = isMidFlowSubmitShapedStep({
12802
- step,
12803
- submitStep,
12804
- isFinalStep,
12805
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12806
- 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,
12807
13186
  });
13187
+ const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12808
13188
  const retryUrlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, retryPost.url) &&
12809
13189
  (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, retryMidFlowSubmit ? pre.url : undefined);
12810
- const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
12811
- 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);
12812
13193
  const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
12813
13194
  // Element-scoped selection read-back for the n+16 fallback — the same
12814
13195
  // authoritative signal the primary verifier uses via `verifyDomEffect`,
@@ -12878,6 +13259,8 @@ async function executeStepWithHealing(params) {
12878
13259
  .evaluate(disabledExpr)
12879
13260
  .catch(() => false);
12880
13261
  })());
13262
+ if (probeResult.kind === "click")
13263
+ lastClickBlockedByDisabled = clickBlockedByDisabled;
12881
13264
  const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
12882
13265
  const clickBlockedByInvalid = clickWasDomOnly &&
12883
13266
  (await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0)) >
@@ -12903,10 +13286,14 @@ async function executeStepWithHealing(params) {
12903
13286
  const retryDestinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
12904
13287
  // Only the verdict sees the pre.url exemption, and only for a demonstrable
12905
13288
  // page advance: bare network/DOM signals stay gated on the landed URL.
12906
- const retryMidFlowDestinationPlausible = retryDestinationPlausible ||
12907
- (retryMidFlowSubmit &&
12908
- (0, phantom_click_1.hasPageAdvanced)(pre, retryPost) &&
12909
- (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
+ });
12910
13297
  const retryNetworkIsRealAdvance = retryNetworkFired &&
12911
13298
  retryDestinationPlausible &&
12912
13299
  (await waitForTransitionBody({
@@ -13014,6 +13401,7 @@ async function executeStepWithHealing(params) {
13014
13401
  elementStateChanged: retrySelectionStateChanged,
13015
13402
  isSubmitShapedStep: retryMidFlowSubmit || retrySubmitShaped,
13016
13403
  isMidFlowSubmitStep: retryMidFlowSubmit,
13404
+ advanceBaseline: retryAdvanceBaseline,
13017
13405
  destinationPlausible: retryMidFlowDestinationPlausible,
13018
13406
  });
13019
13407
  // Destination-plausibility audit of every remaining disjunct in
@@ -13050,6 +13438,10 @@ async function executeStepWithHealing(params) {
13050
13438
  (n16ClickDelivered &&
13051
13439
  (!retrySubmitShaped || retryMidFlowSubmit) &&
13052
13440
  retryVerdict === "effective") ||
13441
+ (n16ClickDelivered &&
13442
+ retryMidFlowSubmit &&
13443
+ retryPageAdvanced &&
13444
+ retryMidFlowDestinationPlausible) ||
13053
13445
  (n16ClickDelivered &&
13054
13446
  weakDomSignalsAllowed &&
13055
13447
  retryDestinationPlausible &&
@@ -13149,12 +13541,15 @@ async function executeStepWithHealing(params) {
13149
13541
  isFinalStep,
13150
13542
  submitStep,
13151
13543
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13152
- elementSubmitShaped: resolvedElementIsSubmitShaped,
13544
+ elementSubmitShaped: retryResolvedElementIsSubmitShaped,
13153
13545
  page,
13154
13546
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
13155
13547
  signalCounter,
13156
13548
  preNetworkCount: pre.networkCount,
13157
- preVisibleTextSignature: pre.visibleTextSignature,
13549
+ preVisibleTextSignature: retryAdvanceBaseline.visibleTextSignature,
13550
+ preVisibleText: retryAdvanceBaseline.visibleText,
13551
+ preVisibleTextDigest: retryAdvanceBaseline.visibleTextDigest,
13552
+ settledPostVisibleText: retryPost.visibleText,
13158
13553
  submitInput: {
13159
13554
  pageUrl: retryPost.url,
13160
13555
  pageTitle,
@@ -13170,16 +13565,14 @@ async function executeStepWithHealing(params) {
13170
13565
  submittedStateSelectors: matchedSubmittedSelectors,
13171
13566
  },
13172
13567
  });
13173
- const retryMidFlowAdvanceCredited = (retryMidFlowSubmit &&
13174
- retryVerdict === "effective" &&
13175
- (0, phantom_click_1.hasPageAdvanced)(pre, retryPost)) ||
13176
- isNullVerdictAdvanceCredited({
13177
- midFlowSubmit: retryMidFlowSubmit,
13178
- judgeUnavailable: judgeVerdict === null,
13179
- networkAdvanced: retryNetworkFired && retryDestinationPlausible,
13180
- pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, retryPost),
13181
- invalidMarkerCount,
13182
- });
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
+ });
13183
13576
  if (retryMidFlowAdvanceCredited && judgeVerdict?.verified !== true) {
13184
13577
  logger.info(`n+16 fallback mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
13185
13578
  }
@@ -13358,12 +13751,7 @@ async function executeStepWithHealing(params) {
13358
13751
  // (triedSelectors), which is empty on an "unresolved" verdict, so a
13359
13752
  // non-submit step with that verdict falls through to the normal
13360
13753
  // ladder instead of escalating.
13361
- const escalationTarget = isStepSubmitShaped({
13362
- step,
13363
- submitStep,
13364
- isFinalStep,
13365
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13366
- })
13754
+ const escalationTarget = primarySubmitShaped
13367
13755
  ? "escalating attempt 2 to deep-submit-locator"
13368
13756
  : phantomClickAfterAttempt1
13369
13757
  ? "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)"
@@ -13375,17 +13763,16 @@ async function executeStepWithHealing(params) {
13375
13763
  // Treat the canonical submit click as "final" for this predicate
13376
13764
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
13377
13765
  // above for the same gate-widening rationale.
13378
- isFinalStep: isStepSubmitShaped({
13379
- step,
13380
- submitStep,
13381
- isFinalStep,
13382
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13383
- }),
13766
+ isFinalStep: primarySubmitShaped,
13384
13767
  requireSubmitEndpoint,
13385
13768
  resolvedMethod: record.resolvedMethod,
13386
13769
  effectSignals,
13387
13770
  preSubmitInvalidCount,
13388
13771
  postAttemptInvalidCount,
13772
+ midFlowAdvanced: !isGenuineFinalSubmit({
13773
+ isFinalStep,
13774
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13775
+ }) && (0, phantom_click_1.hasSettledPageAdvanced)(advanceBaseline, post),
13389
13776
  });
13390
13777
  if (earlyExit) {
13391
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`;
@@ -13447,6 +13834,7 @@ async function executeStepWithHealing(params) {
13447
13834
  submitStep,
13448
13835
  isFinalStep,
13449
13836
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13837
+ elementSubmitShaped: !stepDeclaredNonSubmit,
13450
13838
  }),
13451
13839
  });
13452
13840
  if (fallbackTarget) {
@@ -13455,6 +13843,8 @@ async function executeStepWithHealing(params) {
13455
13843
  return "completed";
13456
13844
  }
13457
13845
  }
13846
+ if (await creditStepStartAdvance("cascade-exhausted"))
13847
+ return "completed";
13458
13848
  const cascadeExhaustObservedFinal = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
13459
13849
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
13460
13850
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -13550,6 +13940,9 @@ async function executeNavigateStep(params) {
13550
13940
  * than let the flow report phantom success.
13551
13941
  */
13552
13942
  async function runHealingFlow(deps) {
13943
+ return (0, runtime_capture_store_1.withRuntimeCaptureStore)((store) => runHealingFlowScoped(deps, store));
13944
+ }
13945
+ async function runHealingFlowScoped(deps, store) {
13553
13946
  const { stagehand, page, steps, logger, anthropic, rephraseModel, uploadFixture, maxFlowMs } = deps;
13554
13947
  for (const secret of deps.secrets ?? [])
13555
13948
  (0, pii_redaction_1.registerSecretValue)(secret);
@@ -13579,6 +13972,7 @@ async function runHealingFlow(deps) {
13579
13972
  recentCaptures,
13580
13973
  recentCaptureMeta,
13581
13974
  getCurrentPhase: () => "flow",
13975
+ onCapture: (capture, filename) => (0, runtime_capture_store_1.recordRuntimeCapture)(store, filename, capture),
13582
13976
  getCurrentPageOrigin: () => {
13583
13977
  try {
13584
13978
  return new URL(activePage.url()).origin;