@enricai/barnacle 1.7.5 → 1.9.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 (35) hide show
  1. package/dist/config.d.ts +2 -2
  2. package/dist/scraper/behavioral-signals.d.ts +1 -1
  3. package/dist/scraper/behavioral-signals.js +1 -1
  4. package/dist/scraper/deep-locator-candidates.d.ts +1 -1
  5. package/dist/scraper/deep-locator-candidates.js +1 -1
  6. package/dist/scraper/deep-locator-click.d.ts +1 -1
  7. package/dist/scraper/deep-locator-click.js +1 -1
  8. package/dist/scraper/deep-locator-fake.d.ts +2 -2
  9. package/dist/scraper/deep-locator-fake.d.ts.map +1 -1
  10. package/dist/scraper/deep-locator-fake.js +2 -2
  11. package/dist/scraper/deep-locator-fake.js.map +1 -1
  12. package/dist/scraper/flow-runner.d.ts +56 -3
  13. package/dist/scraper/flow-runner.d.ts.map +1 -1
  14. package/dist/scraper/flow-runner.js +256 -20
  15. package/dist/scraper/flow-runner.js.map +1 -1
  16. package/dist/scraper/frame-target.d.ts +1 -1
  17. package/dist/scraper/frame-target.js +2 -2
  18. package/dist/scraper/http-client.d.ts +1 -1
  19. package/dist/scripts/recon-browser.d.ts +7 -4
  20. package/dist/scripts/recon-browser.d.ts.map +1 -1
  21. package/dist/scripts/recon-browser.js +37 -10
  22. package/dist/scripts/recon-browser.js.map +1 -1
  23. package/dist/scripts/recon-generate-multicall-fixture.d.ts +1 -1
  24. package/dist/scripts/recon-generate-multicall-fixture.js +1 -1
  25. package/dist/scripts/recon-generate.d.ts +2 -2
  26. package/dist/scripts/recon-generate.d.ts.map +1 -1
  27. package/dist/scripts/recon-generate.js +29 -4
  28. package/dist/scripts/recon-generate.js.map +1 -1
  29. package/dist/scripts/recon-replay-jobs.js +2 -2
  30. package/dist/scripts/recon-replay-jobs.js.map +1 -1
  31. package/dist/testing/resume-fixture.d.ts +7 -0
  32. package/dist/testing/resume-fixture.d.ts.map +1 -1
  33. package/dist/testing/resume-fixture.js +9 -1
  34. package/dist/testing/resume-fixture.js.map +1 -1
  35. package/package.json +1 -1
@@ -22,6 +22,7 @@ exports.findRecentBackendError = findRecentBackendError;
22
22
  exports.findRecentPageTransition = findRecentPageTransition;
23
23
  exports.isWizardExitAction = isWizardExitAction;
24
24
  exports.isAdvanceStep = isAdvanceStep;
25
+ exports.shouldWarnMissingAdvancePattern = shouldWarnMissingAdvancePattern;
25
26
  exports.parseCaptureIndex = parseCaptureIndex;
26
27
  exports.latestCaptureIndex = latestCaptureIndex;
27
28
  exports.capturesAfterIndex = capturesAfterIndex;
@@ -51,6 +52,7 @@ exports.extractSubmitFailureEvidence = extractSubmitFailureEvidence;
51
52
  exports.extractGaEventEvidence = extractGaEventEvidence;
52
53
  exports.normalizeDateValue = normalizeDateValue;
53
54
  exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
55
+ exports.fillTextDatepickerInput = fillTextDatepickerInput;
54
56
  exports.verifyFillReadback = verifyFillReadback;
55
57
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
56
58
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
@@ -78,6 +80,7 @@ const node_fs_1 = require("node:fs");
78
80
  const node_os_1 = require("node:os");
79
81
  const node_path_1 = require("node:path");
80
82
  const ai_1 = require("ai");
83
+ const date_fns_1 = require("date-fns");
81
84
  const config_1 = require("../config");
82
85
  const errors_1 = require("../lib/errors");
83
86
  const error_messages_1 = require("../lib/llm/judges/error-messages");
@@ -530,8 +533,8 @@ async function snapshotPage(target, signalCounter, page) {
530
533
  * application despite returning a 2xx HTTP status. Many ATSs use a "200 OK
531
534
  * with rejection envelope" pattern instead of a 4xx: one JSON-envelope ATS returns
532
535
  * `{not_qualified: true, error: "Not qualified reason: <field>"}`,
533
- * Greenhouse uses `{rejected: true, reason: "..."}`, Lever uses
534
- * `{qualified: false, reason: "..."}`, Workday uses
536
+ * others use `{rejected: true, reason: "..."}`,
537
+ * `{qualified: false, reason: "..."}`, or
535
538
  * `{status: "rejected"}`. Empirically verified on a JSON-envelope ATS: 4/6 historical
536
539
  * /integrated_apply 200s on this codebase had `not_qualified: true` and
537
540
  * we treated them as wins because the audit only checked HTTP status.
@@ -694,6 +697,20 @@ function isAdvanceStep(instruction) {
694
697
  const haystack = instruction.toLowerCase();
695
698
  return ADVANCE_STEP_PHRASES.some((p) => haystack.includes(p));
696
699
  }
700
+ /**
701
+ * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
702
+ * `isDomOnlyAdvanceVerified` only vetoes a DOM-only "advance" when
703
+ * `advanceTransitionBodyPattern` is set; a flow with advance steps but no
704
+ * pattern silently loses that veto, so a validation-re-render wizard can desync
705
+ * its step pointer from the page. Pure + exported so the WARN condition is
706
+ * unit-testable without driving the whole runner. Author-side guard-rail only —
707
+ * no behavior change.
708
+ */
709
+ function shouldWarnMissingAdvancePattern(instructions, advanceTransitionBodyPattern) {
710
+ if (advanceTransitionBodyPattern)
711
+ return false;
712
+ return instructions.some((s) => isAdvanceStep(s));
713
+ }
697
714
  /**
698
715
  * Parse the monotonic capture index from a capture filename. Every capture is
699
716
  * written as `<idx>-<phase>-<unix-ms>-<hash>.json` where `<idx>` is a
@@ -928,7 +945,7 @@ function isDomOnlyAdvanceVerified(params) {
928
945
  * **Small-delta reveal credit:** a sub-section unhiding within an
929
946
  * already-loaded page (e.g. a validation-triggered "Work History requirement"
930
947
  * gate message) grows the DOM by far less than a full screen swap — measured
931
- * case: +789B on UCHealth, ~63x below the 5000B full-swap default. Below
948
+ * case: +789B on the top-window site, ~63x below the 5000B full-swap default. Below
932
949
  * `viewSwapMinBytesThreshold`, credit the click anyway when the growth ALSO
933
950
  * changed visible text (`textChanged`) and clears the lower
934
951
  * `viewSwapRevealMinBytesThreshold` floor (default 500B, matching
@@ -960,7 +977,7 @@ function isClickViewSwapVerified(params) {
960
977
  * questions" — when this count grows from 0 (pre-submit) to ≥1 (post-attempt-1),
961
978
  * attempts 2-5 cannot succeed and the cascade should route to replan
962
979
  * immediately instead of burning Stagehand calls. Accepts a `FrameTarget` so a
963
- * wizard embedded in a cross-origin iframe (e.g. UCHealth's Talemetry form) is
980
+ * wizard embedded in a cross-origin iframe (e.g. an embedded apply-wizard form) is
964
981
  * scanned on its own frame; a main-frame target delegates straight to
965
982
  * `page.evaluate`, matching today's behavior byte-for-byte.
966
983
  */
@@ -2150,6 +2167,200 @@ async function fillHtml5DateTimeInput(target, xpath, value) {
2150
2167
  return null;
2151
2168
  }
2152
2169
  }
2170
+ /**
2171
+ * Parse a loosely-formatted date string into calendar parts. The recon flow
2172
+ * passes dates in whatever shape the plan text used (ISO `yyyy-MM`, US
2173
+ * `MM/dd/yyyy`, etc.), but a react-datepicker commits through year/month/day
2174
+ * selection, so we need the numeric parts, not a display string. Uses date-fns
2175
+ * per project convention; returns null when no candidate format matches so the
2176
+ * caller skips the datepicker path rather than picking a wrong month.
2177
+ */
2178
+ function parseDatepickerValue(value) {
2179
+ const candidates = ["yyyy-MM", "yyyy-MM-dd", "MM/dd/yyyy", "MM-dd-yyyy", "MM/yyyy", "M/yyyy"];
2180
+ for (const fmt of candidates) {
2181
+ const parsed = (0, date_fns_1.parse)(value.trim(), fmt, new Date(2000, 0, 1));
2182
+ if ((0, date_fns_1.isValid)(parsed)) {
2183
+ return { year: parsed.getFullYear(), monthIndex: parsed.getMonth(), day: parsed.getDate() };
2184
+ }
2185
+ }
2186
+ return null;
2187
+ }
2188
+ /** Dispatch a real Enter keydown/keyup on the element (Stagehand's Locator has no `press`). */
2189
+ async function dispatchEnterKey(target, xpath) {
2190
+ const expr = `(() => {
2191
+ const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
2192
+ const el = r.singleNodeValue;
2193
+ if (!el) return "no-element";
2194
+ const opts = { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true };
2195
+ el.dispatchEvent(new KeyboardEvent("keydown", opts));
2196
+ el.dispatchEvent(new KeyboardEvent("keyup", opts));
2197
+ return "dispatched";
2198
+ })()`;
2199
+ try {
2200
+ await target.evaluate(expr);
2201
+ }
2202
+ catch {
2203
+ // best-effort: an Enter-dispatch failure shouldn't fail the fill
2204
+ }
2205
+ }
2206
+ /** A readback whose value is a non-empty, date-shaped string counts as committed. */
2207
+ function isCommittedDateReadback(readback) {
2208
+ if (readback === null)
2209
+ return false;
2210
+ if (readback.outcome === "rejected")
2211
+ return false;
2212
+ // A datepicker reformats what it accepts (e.g. "01/2020" → "2020-01"), so
2213
+ // "differs" is a commit, not a rejection — require only a non-empty value
2214
+ // that contains a 4-digit year, ruling out stray placeholder text.
2215
+ return readback.postValue.trim().length > 0 && /\d{4}/.test(readback.postValue);
2216
+ }
2217
+ /**
2218
+ * Fill a `type="text"` datepicker widget (react-datepicker — the standard
2219
+ * ATS start/end-date control), which `fillHtml5DateTimeInput`
2220
+ * cannot: react-datepicker rejects a programmatic `.value` write and commits
2221
+ * only through its own keyboard/calendar `onChange`. Returns `null` for any
2222
+ * input that is NOT a date-like text control, so every other text field stays
2223
+ * on today's exact fill path.
2224
+ *
2225
+ * Two gestures, in order:
2226
+ * 1. Keyboard entry — real keystrokes via `locator.type` (the proven
2227
+ * framework-control path at {@link verifyDomEffect}) + Enter. Works for
2228
+ * *typeable* datepickers.
2229
+ * 2. Open-and-pick — click to open `.react-datepicker`, then either set the
2230
+ * year/month `.range-select` dropdowns and click the `.react-datepicker__month-N`
2231
+ * cell (month/year-picker variant — the reported ATS widget, which is
2232
+ * picker-only and ignores typed input) or click the matching
2233
+ * `.react-datepicker__day` cell (day-grid variant). Commit fires on the cell
2234
+ * click.
2235
+ *
2236
+ * The committed value is confirmed via {@link verifyFillReadback}; a datepicker
2237
+ * reformats what it accepts, so a non-empty date-shaped readback is success.
2238
+ *
2239
+ * Return contract mirrors {@link fillHtml5DateTimeInput}: `null` means "not a
2240
+ * datepicker — caller falls through to the generic readback verifier"; a
2241
+ * non-null object means "this WAS a datepicker, here's the outcome". A non-null
2242
+ * `filled:false` therefore deliberately TERMINATES the cascade (the caller
2243
+ * records the failure and does NOT run the generic verifier) — a gated-in
2244
+ * datepicker that neither gesture commits is a real fill failure, not an
2245
+ * unrecognized field.
2246
+ *
2247
+ * Site-agnostic: react-datepicker's markup + commit model is universal across
2248
+ * ATS tenants using it.
2249
+ */
2250
+ async function fillTextDatepickerInput(target, selector, value) {
2251
+ const xpath = xpathBody(selector);
2252
+ if (!xpath)
2253
+ return null;
2254
+ const parts = parseDatepickerValue(value);
2255
+ if (!parts)
2256
+ return null;
2257
+ // Gate: only proceed for a text input that looks like a datepicker. The
2258
+ // definitive signal is a `.react-datepicker__input-container` wrapper; we
2259
+ // also accept id/name/class/aria date hints for non-react libraries.
2260
+ const gateExpr = `(() => {
2261
+ const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
2262
+ const el = r.singleNodeValue;
2263
+ if (!el || el.tagName !== "INPUT") return { match: false };
2264
+ const type = (el.getAttribute("type") || "text").toLowerCase();
2265
+ if (type !== "text") return { match: false };
2266
+ const inContainer = !!(el.closest && el.closest(".react-datepicker__input-container, .react-datepicker-wrapper"));
2267
+ const hint = ((el.id || "") + " " + (el.name || "") + " " + (el.className || "") + " " + (el.getAttribute("aria-label") || "") + " " + (el.getAttribute("placeholder") || "")).toLowerCase();
2268
+ const looksDate = /date|datepicker/.test(hint) || /\\bmm[\\/-]?(dd[\\/-]?)?yyyy\\b/.test(hint);
2269
+ return { match: inContainer || looksDate };
2270
+ })()`;
2271
+ try {
2272
+ const gate = await target.evaluate(gateExpr);
2273
+ if (!gate || typeof gate !== "object" || gate.match !== true) {
2274
+ return null;
2275
+ }
2276
+ }
2277
+ catch {
2278
+ return null;
2279
+ }
2280
+ // Strategy 1 — keyboard entry (typeable datepickers). Mirrors the Angular
2281
+ // reactive-form re-type at verifyDomEffect: real CDP keystrokes flow through
2282
+ // the widget's own onChange, unlike a programmatic value write.
2283
+ const typed = (0, date_fns_1.format)(new Date(parts.year, parts.monthIndex, parts.day), "MM/dd/yyyy");
2284
+ try {
2285
+ const locator = target.locator(selector).first();
2286
+ await locator.fill("");
2287
+ await locator.type(typed, { delay: 50 });
2288
+ // react-datepicker commits typed input on Enter/blur. The Stagehand Locator
2289
+ // has no `press`, so dispatch the Enter keydown/keyup from page context
2290
+ // (the widget's own keydown handler listens here) then blur via the shared
2291
+ // jQuery-change helper.
2292
+ await dispatchEnterKey(target, xpath);
2293
+ await dispatchJqueryChangeEvent(target, selector);
2294
+ const readback = await verifyFillReadback(target, xpath, value);
2295
+ if (isCommittedDateReadback(readback)) {
2296
+ return { filled: true, postValue: readback.postValue, strategy: "keyboard" };
2297
+ }
2298
+ }
2299
+ catch {
2300
+ // fall through to open-and-pick
2301
+ }
2302
+ // Strategy 2 — open-and-pick. Required for picker-only widgets (the reported
2303
+ // month/year picker ignores typed input entirely).
2304
+ try {
2305
+ const locator = target.locator(selector).first();
2306
+ await locator.click();
2307
+ }
2308
+ catch {
2309
+ return { filled: false, postValue: "", strategy: "none" };
2310
+ }
2311
+ const pickExpr = `(() => {
2312
+ const year = ${parts.year};
2313
+ const monthIndex = ${parts.monthIndex};
2314
+ const day = ${parts.day};
2315
+ const cal = document.querySelector(".react-datepicker");
2316
+ if (!cal) return { picked: false, reason: "no-calendar" };
2317
+ const fireChange = (el) => { el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); };
2318
+ const monthCells = cal.querySelectorAll(".react-datepicker__month-text");
2319
+ const dayGrid = cal.querySelectorAll(".react-datepicker__day:not(.react-datepicker__day--outside-month):not(.react-datepicker__day--disabled)");
2320
+ // Month/year-picker variant: click the target month cell (0-based month
2321
+ // index maps to the __month-N class) — THIS is the commit. The year/month
2322
+ // dropdowns are set first only so the calendar shows the right year's month
2323
+ // page. NOTE: "range-select" is a SITE-SPECIFIC class (the ATS site's own
2324
+ // CSS), not a react-datepicker class; the bare "select" fallback covers
2325
+ // other wrappers.
2326
+ // Stock react-datepicker renders its month/year dropdowns as clickable divs,
2327
+ // so on those the select loop is inert and the month-cell click carries it.
2328
+ if (monthCells.length > 0) {
2329
+ const selects = cal.querySelectorAll("select.range-select, select");
2330
+ for (const sel of selects) {
2331
+ const opts = Array.from(sel.options).map((o) => (o.value || "").trim());
2332
+ const yearOpt = opts.indexOf(String(year));
2333
+ if (yearOpt >= 0) { sel.selectedIndex = yearOpt; fireChange(sel); continue; }
2334
+ const monthOpt = opts.indexOf(String(monthIndex));
2335
+ if (monthOpt >= 0) { sel.selectedIndex = monthOpt; fireChange(sel); }
2336
+ }
2337
+ const monthCell = cal.querySelector(".react-datepicker__month-" + monthIndex + ".react-datepicker__month-text");
2338
+ const cell = monthCell || cal.querySelectorAll(".react-datepicker__month-text")[monthIndex];
2339
+ if (cell) { cell.click(); return { picked: true, variant: "month-year" }; }
2340
+ return { picked: false, reason: "no-month-cell" };
2341
+ }
2342
+ // Day-grid variant: click the day cell matching the parsed day number.
2343
+ if (dayGrid.length > 0) {
2344
+ for (const cell of dayGrid) {
2345
+ if ((cell.textContent || "").trim() === String(day)) { cell.click(); return { picked: true, variant: "day-grid" }; }
2346
+ }
2347
+ return { picked: false, reason: "no-day-cell" };
2348
+ }
2349
+ return { picked: false, reason: "empty-calendar" };
2350
+ })()`;
2351
+ try {
2352
+ const picked = await target.evaluate(pickExpr);
2353
+ const didPick = !!picked && typeof picked === "object" && picked.picked === true;
2354
+ const readback = await verifyFillReadback(target, xpath, value);
2355
+ if (didPick && isCommittedDateReadback(readback)) {
2356
+ return { filled: true, postValue: readback.postValue, strategy: "calendar-pick" };
2357
+ }
2358
+ return { filled: false, postValue: readback?.postValue ?? "", strategy: "none" };
2359
+ }
2360
+ catch {
2361
+ return { filled: false, postValue: "", strategy: "none" };
2362
+ }
2363
+ }
2153
2364
  /**
2154
2365
  * Read back an element's value after a fill action and compare to the
2155
2366
  * expected value. Catches silent-value-rejection cases that the verifier's
@@ -2217,7 +2428,7 @@ function harvestFieldErrors(body) {
2217
2428
  return [];
2218
2429
  const out = [];
2219
2430
  const rec = body;
2220
- // Singular `{error: "message"}` shape used by JSON-envelope ATSs, Lever, Greenhouse,
2431
+ // Singular `{error: "message"}` shape used by JSON-envelope ATSs
2221
2432
  // and any REST API following the {error:string} terse-error convention.
2222
2433
  // Verified on a JSON-envelope ATS tenant: /integrated_apply 422 body is
2223
2434
  // exactly {"error":"Resume is blank"} — no `errors`, no `message`. Before
@@ -2586,7 +2797,7 @@ async function surfaceAndUpload(params) {
2586
2797
  }
2587
2798
  }
2588
2799
  // The file-chooser CDP interception below must run on the upload target's
2589
- // OWN session — an OOPIF (e.g. UCHealth's Talemetry wizard) has its own CDP
2800
+ // OWN session — an OOPIF (e.g. a cross-origin OOPIF apply wizard) has its own CDP
2590
2801
  // target, and a chooser it opens is only observable via that frame's
2591
2802
  // session, not the main session. Main-frame targets fall back to
2592
2803
  // page.getSessionForFrame(page.mainFrameId()), matching today's behavior.
@@ -5721,20 +5932,42 @@ async function executeStepWithHealing(params) {
5721
5932
  }
5722
5933
  }
5723
5934
  else {
5724
- // Fix I: not a date input verify the regular fill landed.
5725
- // Catches silent-value-rejection cases (HTML5 type validation
5726
- // on number/email/url/tel inputs, framework-controlled-
5727
- // component rejection, masked-input library reformatting).
5728
- // Generic primitive that the verifier's existing signals
5729
- // (network/url/dom/htmlDelta/textChanged) miss.
5730
- const readback = await verifyFillReadback(dateFillTarget, overriddenTarget.selector, fillValue);
5731
- if (readback !== null) {
5732
- if (readback.outcome === "rejected") {
5733
- record.errorMessage = `fill-value-rejected: tried "${fillValue.slice(0, 60)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
5935
+ // Not an HTML5 date input. First try the text-datepicker
5936
+ // primitive (react-datepicker ATS start/end-date
5937
+ // controls): those are `type="text"`, so fillHtml5DateTimeInput
5938
+ // returns null, and the generic fill's value-write never commits
5939
+ // the widget. Returns null for any non-datepicker text input, so
5940
+ // every other field falls through to the readback verifier below
5941
+ // unchanged.
5942
+ const datepickerFill = await fillTextDatepickerInput(dateFillTarget, overriddenTarget.selector, fillValue);
5943
+ if (datepickerFill !== null) {
5944
+ record.errorMessage = datepickerFill.filled
5945
+ ? `text-datepicker-fill: filled via ${datepickerFill.strategy} "${datepickerFill.postValue}"`
5946
+ : `text-datepicker-fill: failed to commit "${fillValue.slice(0, 60)}" (post=${datepickerFill.postValue})`;
5947
+ if (datepickerFill.filled) {
5948
+ record.actResultSuccess = true;
5949
+ resolvedAction = overriddenTarget;
5950
+ }
5951
+ else {
5734
5952
  record.actResultSuccess = false;
5735
5953
  }
5736
- else if (readback.outcome === "differs") {
5737
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
5954
+ }
5955
+ else {
5956
+ // Fix I: not a date input — verify the regular fill landed.
5957
+ // Catches silent-value-rejection cases (HTML5 type validation
5958
+ // on number/email/url/tel inputs, framework-controlled-
5959
+ // component rejection, masked-input library reformatting).
5960
+ // Generic primitive that the verifier's existing signals
5961
+ // (network/url/dom/htmlDelta/textChanged) miss.
5962
+ const readback = await verifyFillReadback(dateFillTarget, overriddenTarget.selector, fillValue);
5963
+ if (readback !== null) {
5964
+ if (readback.outcome === "rejected") {
5965
+ record.errorMessage = `fill-value-rejected: tried "${fillValue.slice(0, 60)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
5966
+ record.actResultSuccess = false;
5967
+ }
5968
+ else if (readback.outcome === "differs") {
5969
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
5970
+ }
5738
5971
  }
5739
5972
  }
5740
5973
  }
@@ -6052,10 +6285,10 @@ async function executeStepWithHealing(params) {
6052
6285
  }
6053
6286
  // Client-side view-swap gate: credit a click that produces substantial
6054
6287
  // DOM growth (≥5KB) with zero network when it's NOT a submit/final step
6055
- // and NOT an advance-pattern step. Fixes the UCHealth "Manual Application"
6288
+ // and NOT an advance-pattern step. Fixes the top-window site "Manual Application"
6056
6289
  // case where a +49KB DOM-only view swap was scored as "no observable effect".
6057
6290
  // Below that threshold, also credits a smaller text-changing reveal (≥500B) —
6058
- // fixes the UCHealth Work-History gate-message reveal (+789B) that used to
6291
+ // fixes the top-window site Work-History gate-message reveal (+789B) that used to
6059
6292
  // cascade to a 5-attempt failure and global replan.
6060
6293
  const clickViewSwapVerified = isClickViewSwapVerified({
6061
6294
  resolvedAction,
@@ -6707,6 +6940,9 @@ async function runHealingFlow(deps) {
6707
6940
  }
6708
6941
  },
6709
6942
  });
6943
+ if (shouldWarnMissingAdvancePattern(steps.map((s) => s.instruction), deps.advanceTransitionBodyPattern ?? null)) {
6944
+ logger.warn("flow has advance steps but no advanceTransitionBodyPattern — DOM-only advance guard is disarmed; pointer/page desync is possible on validation-re-render wizards");
6945
+ }
6710
6946
  try {
6711
6947
  for (const [i, s] of steps.entries()) {
6712
6948
  if (maxFlowMs !== undefined && Date.now() - start > maxFlowMs) {