@enricai/barnacle 1.6.1 → 1.6.3

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 (105) hide show
  1. package/README.md +36 -29
  2. package/dist/api/errors.d.ts +3 -3
  3. package/dist/api/errors.d.ts.map +1 -1
  4. package/dist/api/errors.js +4 -4
  5. package/dist/api/errors.js.map +1 -1
  6. package/dist/api/helpers/reply.d.ts +1 -1
  7. package/dist/api/helpers/reply.js +1 -1
  8. package/dist/cache/keyed-ttl-cache.d.ts +4 -4
  9. package/dist/cache/keyed-ttl-cache.js +2 -2
  10. package/dist/lib/applicant-payload.d.ts +4 -4
  11. package/dist/lib/applicant-payload.js +4 -4
  12. package/dist/lib/application-address.d.ts +3 -3
  13. package/dist/lib/application-address.js +5 -5
  14. package/dist/lib/application-address.js.map +1 -1
  15. package/dist/lib/application-resume.d.ts +4 -4
  16. package/dist/lib/application-resume.js +4 -4
  17. package/dist/lib/dispatch-metrics.js +1 -1
  18. package/dist/lib/llm/judges/verify-submit.d.ts +1 -1
  19. package/dist/lib/llm/judges/verify-submit.js +1 -1
  20. package/dist/lib/llm/schemas.js +3 -3
  21. package/dist/lib/phone.d.ts +1 -1
  22. package/dist/lib/phone.js +1 -1
  23. package/dist/lib/telemetry/call-types.d.ts +1 -1
  24. package/dist/lib/telemetry/call-types.js +1 -1
  25. package/dist/lib/tracking-click.d.ts +5 -3
  26. package/dist/lib/tracking-click.d.ts.map +1 -1
  27. package/dist/lib/tracking-click.js +5 -3
  28. package/dist/lib/tracking-click.js.map +1 -1
  29. package/dist/lib/us-states.d.ts +1 -1
  30. package/dist/lib/us-states.js +1 -1
  31. package/dist/plugins/loader.js +2 -2
  32. package/dist/scraper/cookie-jar.d.ts +2 -2
  33. package/dist/scraper/cookie-jar.js +2 -2
  34. package/dist/scraper/deep-query.d.ts +36 -0
  35. package/dist/scraper/deep-query.d.ts.map +1 -0
  36. package/dist/scraper/deep-query.js +87 -0
  37. package/dist/scraper/deep-query.js.map +1 -0
  38. package/dist/scraper/errors.d.ts +12 -14
  39. package/dist/scraper/errors.d.ts.map +1 -1
  40. package/dist/scraper/errors.js +12 -17
  41. package/dist/scraper/errors.js.map +1 -1
  42. package/dist/scraper/flow-runner.d.ts +97 -30
  43. package/dist/scraper/flow-runner.d.ts.map +1 -1
  44. package/dist/scraper/flow-runner.js +304 -104
  45. package/dist/scraper/flow-runner.js.map +1 -1
  46. package/dist/scraper/http-client.d.ts +11 -1
  47. package/dist/scraper/http-client.d.ts.map +1 -1
  48. package/dist/scraper/http-client.js +90 -3
  49. package/dist/scraper/http-client.js.map +1 -1
  50. package/dist/scraper/phantom-click.d.ts +45 -0
  51. package/dist/scraper/phantom-click.d.ts.map +1 -0
  52. package/dist/scraper/phantom-click.js +37 -0
  53. package/dist/scraper/phantom-click.js.map +1 -0
  54. package/dist/scraper/raw-fetch.d.ts +2 -2
  55. package/dist/scraper/raw-fetch.js +1 -1
  56. package/dist/scraper/require-response-field.d.ts +1 -1
  57. package/dist/scraper/require-response-field.js +1 -1
  58. package/dist/scraper/session-browserbase.d.ts +48 -0
  59. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  60. package/dist/scraper/session-browserbase.js +10 -5
  61. package/dist/scraper/session-browserbase.js.map +1 -1
  62. package/dist/scraper/session-shared.d.ts +6 -0
  63. package/dist/scraper/session-shared.d.ts.map +1 -1
  64. package/dist/scraper/session-shared.js.map +1 -1
  65. package/dist/scraper/session-warmup.d.ts +1 -1
  66. package/dist/scraper/session-warmup.js +1 -1
  67. package/dist/scraper/submit-control.d.ts +51 -0
  68. package/dist/scraper/submit-control.d.ts.map +1 -0
  69. package/dist/scraper/submit-control.js +142 -0
  70. package/dist/scraper/submit-control.js.map +1 -0
  71. package/dist/scripts/migrate-telemetry-dir-names.d.ts +2 -2
  72. package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +1 -1
  73. package/dist/scripts/migrate-telemetry-dir-names.js +1 -1
  74. package/dist/scripts/recon-browser.d.ts +25 -20
  75. package/dist/scripts/recon-browser.d.ts.map +1 -1
  76. package/dist/scripts/recon-browser.js +65 -56
  77. package/dist/scripts/recon-browser.js.map +1 -1
  78. package/dist/scripts/recon-generate.d.ts +52 -7
  79. package/dist/scripts/recon-generate.d.ts.map +1 -1
  80. package/dist/scripts/recon-generate.js +73 -31
  81. package/dist/scripts/recon-generate.js.map +1 -1
  82. package/dist/scripts/recon-http.d.ts +36 -5
  83. package/dist/scripts/recon-http.d.ts.map +1 -1
  84. package/dist/scripts/recon-http.js +77 -47
  85. package/dist/scripts/recon-http.js.map +1 -1
  86. package/dist/scripts/recon-replay-jobs.d.ts +32 -3
  87. package/dist/scripts/recon-replay-jobs.d.ts.map +1 -1
  88. package/dist/scripts/recon-replay-jobs.js +46 -29
  89. package/dist/scripts/recon-replay-jobs.js.map +1 -1
  90. package/dist/scripts/recon-shared.d.ts +45 -0
  91. package/dist/scripts/recon-shared.d.ts.map +1 -1
  92. package/dist/scripts/recon-shared.js +117 -0
  93. package/dist/scripts/recon-shared.js.map +1 -1
  94. package/dist/scripts/recon-summarize.d.ts +5 -1
  95. package/dist/scripts/recon-summarize.d.ts.map +1 -1
  96. package/dist/scripts/recon-summarize.js +19 -7
  97. package/dist/scripts/recon-summarize.js.map +1 -1
  98. package/dist/site-plugin.d.ts +4 -4
  99. package/dist/testing/persona-fixture.d.ts +4 -4
  100. package/dist/testing/persona-fixture.d.ts.map +1 -1
  101. package/dist/testing/persona-fixture.js +4 -4
  102. package/dist/testing/persona-fixture.js.map +1 -1
  103. package/dist/types/dispatch-metrics.d.ts +1 -1
  104. package/dist/types/dispatch-metrics.js +1 -1
  105. package/package.json +1 -1
@@ -60,6 +60,7 @@ exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
60
60
  exports.buildRadioIdXPath = buildRadioIdXPath;
61
61
  exports.selectRadioGroupOption = selectRadioGroupOption;
62
62
  exports.narrowInvalidFormControl = narrowInvalidFormControl;
63
+ exports.formatStepPrefix = formatStepPrefix;
63
64
  exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
64
65
  exports.executeStepWithHealing = executeStepWithHealing;
65
66
  exports.waitForSpaReady = waitForSpaReady;
@@ -81,7 +82,9 @@ const logging_1 = require("../lib/logging");
81
82
  const call_capture_1 = require("../lib/telemetry/call-capture");
82
83
  const call_types_1 = require("../lib/telemetry/call-types");
83
84
  const errors_2 = require("../scraper/errors");
85
+ const phantom_click_1 = require("../scraper/phantom-click");
84
86
  const stagehand_guard_1 = require("../scraper/stagehand-guard");
87
+ const submit_control_1 = require("../scraper/submit-control");
85
88
  const recon_shared_1 = require("../scripts/recon-shared");
86
89
  const logger = (0, logging_1.getLogger)({ name: "scraper/flow-runner" });
87
90
  /** Cap on the rolling capture-filename window held in memory for failure dumps. */
@@ -352,7 +355,7 @@ const SELECT_SETTLE_MS = 400;
352
355
  /**
353
356
  * Extra bounded poll window for a network-only advance whose real
354
357
  * `TransitionWorklet(type="next")` POST lands AFTER the `STEP_PAUSE_MS`
355
- * snapshot. HCA/Talemetry's "Next" click fires a fast `WorkletPayload` autosave
358
+ * snapshot. The wizard ATS's "Next" click fires a fast `WorkletPayload` autosave
356
359
  * first, then the actual transition ~0.6-2s+ later — so a one-shot check
357
360
  * false-negatives the advance, retries the click, and the stale retry fires a
358
361
  * `back` that bounces the wizard. Additive to `STEP_PAUSE_MS`; only spent on an
@@ -426,7 +429,7 @@ exports.STEP_WATCHDOG_MS = 120_000;
426
429
  * browser-context `page.evaluate` expression string, where a Node RegExp can't
427
430
  * cross the boundary.
428
431
  *
429
- * Why the additions beyond the original Angular/Bootstrap set: HCA's Talemetry
432
+ * Why the additions beyond the original Angular/Bootstrap set: the Angular/MUI
430
433
  * wizard mixes Angular pages AND Material-UI (React) pages (Review, self-ID,
431
434
  * COMPENSATION). MUI marks invalid controls with `Mui-error` (class) +
432
435
  * `aria-invalid="true"` (attribute), NONE of which the ng-only regex matched —
@@ -493,11 +496,11 @@ async function snapshotPage(page, signalCounter) {
493
496
  /**
494
497
  * Detect whether a 2xx response body indicates the server REJECTED the
495
498
  * application despite returning a 2xx HTTP status. Many ATSs use a "200 OK
496
- * with rejection envelope" pattern instead of a 4xx: AppCast returns
499
+ * with rejection envelope" pattern instead of a 4xx: one JSON-envelope ATS returns
497
500
  * `{not_qualified: true, error: "Not qualified reason: <field>"}`,
498
501
  * Greenhouse uses `{rejected: true, reason: "..."}`, Lever uses
499
502
  * `{qualified: false, reason: "..."}`, Workday uses
500
- * `{status: "rejected"}`. Empirically verified on AppCast: 4/6 historical
503
+ * `{status: "rejected"}`. Empirically verified on a JSON-envelope ATS: 4/6 historical
501
504
  * /integrated_apply 200s on this codebase had `not_qualified: true` and
502
505
  * we treated them as wins because the audit only checked HTTP status.
503
506
  *
@@ -685,7 +688,7 @@ function latestCaptureIndex(recentCaptures) {
685
688
  * read straight from `capturesDir` — NOT from the in-memory `recentCaptures`
686
689
  * array, which is front-evicted to `RECENT_CAPTURES_WINDOW` and therefore drops
687
690
  * a step's transition when >20 captures flood during the step (measured 43
688
- * across one HCA cascade). Scanning disk by filename index is eviction-proof:
691
+ * across one wizard-ATS cascade). Scanning disk by filename index is eviction-proof:
689
692
  * the transition file is always on disk regardless of array churn. `.decoded.json`
690
693
  * sidecars are excluded (the raw file carries `requestPostData`). Sorted by index
691
694
  * so callers scan in capture order. Returns [] when the dir is unreadable.
@@ -709,7 +712,7 @@ function capturesAfterIndex(preIdx, capturesDir) {
709
712
  * Detects a multi-page-wizard RESTART / backward navigation by scanning the
710
713
  * captures written during this step for a configured restart-signal pattern
711
714
  * (e.g. `init-apply`, `application_canceled=true`). The restart signal is often
712
- * a plain GET (Talemetry's `GET .../init-apply?...&application_canceled=true`),
715
+ * a plain GET (the wizard ATS's `GET .../init-apply?...&application_canceled=true`),
713
716
  * so it scans the raw capture files' `url` field (GETs are written to disk even
714
717
  * though they're dropped from `recentCaptureMeta`). Window scoped by
715
718
  * `preIdx` via {@link capturesAfterIndex} (eviction-proof). Returns the matching
@@ -719,7 +722,7 @@ function capturesAfterIndex(preIdx, capturesDir) {
719
722
  * Does any same-window network capture's REQUEST BODY match the configured
720
723
  * transition pattern? Proves an interior "advance"/"Next" step really moved the
721
724
  * wizard forward when advance and non-advance mutations share one endpoint URL
722
- * (e.g. Talemetry `/gq`: a real advance is a `TransitionWorklet` mutation, a
725
+ * (e.g. the wizard ATS's `/gq`: a real advance is a `TransitionWorklet` mutation, a
723
726
  * field edit is `EditQuestionItem` — same URL, only the body differs, so a
724
727
  * URL/meta-based check can't tell them apart). Window scoped by `preIdx` via
725
728
  * {@link capturesAfterIndex} (disk-scan by filename index — eviction-proof, so a
@@ -730,7 +733,7 @@ function windowHasTransitionBody(params) {
730
733
  const { preIdx, advanceTransitionBodyPattern } = params;
731
734
  if (!advanceTransitionBodyPattern)
732
735
  return false;
733
- const capturesDir = params.capturesDir ?? recon_shared_1.CAPTURES_DIR;
736
+ const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
734
737
  let rx;
735
738
  try {
736
739
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -758,7 +761,7 @@ function windowHasTransitionBody(params) {
758
761
  * Stricter sibling of {@link windowHasTransitionBody}: a same-window capture
759
762
  * whose request body matches the transition pattern AND whose parsed
760
763
  * `variables.input.type === "next"`. The mutation NAME alone is a weak
761
- * distinguisher — on Talemetry a `back` bounce is ALSO a `TransitionWorklet`
764
+ * distinguisher — on the wizard ATS a `back` bounce is ALSO a `TransitionWorklet`
762
765
  * mutation (its body contains the pattern too) and would wrongly count as an
763
766
  * advance; and the fast `WorkletPayload` autosave that precedes the real
764
767
  * transition is a different mutation with no `input.type`. Requiring the parsed
@@ -772,7 +775,7 @@ function windowHasAdvanceTransition(params) {
772
775
  const { preIdx, advanceTransitionBodyPattern } = params;
773
776
  if (!advanceTransitionBodyPattern)
774
777
  return false;
775
- const capturesDir = params.capturesDir ?? recon_shared_1.CAPTURES_DIR;
778
+ const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
776
779
  let rx;
777
780
  try {
778
781
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -805,7 +808,7 @@ function windowHasAdvanceTransition(params) {
805
808
  * the RC2 gate is unit-testable.
806
809
  *
807
810
  * An interior "Next" on an SPA where an advance and a mere field-edit share one
808
- * endpoint (Talemetry `/gq`: TransitionWorklet vs EditQuestionItem — same URL,
811
+ * endpoint (the wizard ATS's `/gq`: TransitionWorklet vs EditQuestionItem — same URL,
809
812
  * different body) can fire a network POST that is NOT a real advance; the
810
813
  * fallback's htmlDelta/textChanged/checked-radio signals are then validation
811
814
  * re-renders / field toggles that don't move the wizard. So for an opted-in
@@ -830,7 +833,7 @@ function shouldVetoFallbackAdvance(params) {
830
833
  * transition fired — a URL change OR a real `type=next` (`networkIsRealAdvance`).
831
834
  * A DOM change alone is a validation re-render / field toggle that never moves
832
835
  * the wizard. Crucially this must veto even when a NON-advancing network POST
833
- * fired (Talemetry's `WorkletPayload` autosave): keying the veto on "no network
836
+ * fired (the wizard ATS's `WorkletPayload` autosave): keying the veto on "no network
834
837
  * at all" let a rephrase that triggered an autosave + DOM reflow false-verify an
835
838
  * advance, desyncing the flow from the wizard. Returns whether the DOM signal is
836
839
  * ALLOWED to verify: false = veto it. Non-advance/field steps, sites without the
@@ -921,7 +924,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
921
924
  * keeps healing opportunistically.
922
925
  */
923
926
  function shouldSkipTechnique(params) {
924
- const { technique, priorAttempts, advanceUnmovedAfterAttempt1 } = params;
927
+ const { technique, priorAttempts, advanceUnmovedAfterAttempt1, phantomClickAfterAttempt1, submitShapedStep, } = params;
925
928
  // Unmoved-advance short-circuit (measured: attempts 2-4 recovered a stuck
926
929
  // advance 0 times in 289 steps). When attempt-1's act-string clicked the Next
927
930
  // and the wizard did NOT move forward (non-advancing POST, or no effect),
@@ -942,6 +945,31 @@ function shouldSkipTechnique(params) {
942
945
  reason: "advance step did not move the wizard on attempt 1; re-observe/re-click cannot advance it — skipping to rephrase/replan",
943
946
  };
944
947
  }
948
+ // Phantom-click short-circuit: attempt 1 clicked something Stagehand
949
+ // believes exists, but pre/post shows zero network, zero URL change, and
950
+ // no real DOM growth — the click almost certainly landed on nothing (the
951
+ // recon-submit-phantom-click bug report's light-DOM resolver can't see
952
+ // into a shadow root / web component). Repeating observe-act /
953
+ // structured-click / observe-act-exclude re-resolves the SAME
954
+ // light-DOM-only view of the page and would no-op identically, so skip
955
+ // straight to deep-submit-locator (attempt 2) instead. llm-rephrase
956
+ // (attempt 5) is never skipped — a differently-worded instruction is still
957
+ // a distinct attempt worth trying if the deep locator also fails. Gated on
958
+ // submitShapedStep: the deep submit-control locator ranks submit-shaped
959
+ // candidates only, so on a non-submit control (e.g. a radio/checkbox) it
960
+ // is a guaranteed no-op — leave the normal ladder intact so
961
+ // structured-click / observe-act-exclude, the techniques that can
962
+ // actually click it, still run.
963
+ if (phantomClickAfterAttempt1 === true &&
964
+ submitShapedStep === true &&
965
+ (technique === "observe-act" ||
966
+ technique === "structured-click" ||
967
+ technique === "observe-act-exclude")) {
968
+ return {
969
+ skip: true,
970
+ reason: "attempt 1 was a phantom click (reported success, zero observable effect) on a submit-shaped step; re-observe/re-click cannot reach a target the light-DOM resolver can't see — escalating to the deep submit-control locator",
971
+ };
972
+ }
945
973
  if (technique === "structured-click") {
946
974
  const anyXpathResolved = priorAttempts.some((a) => a.triedSelectors.length > 0);
947
975
  if (!anyXpathResolved) {
@@ -1013,7 +1041,7 @@ function isSubmitRevealedInvalid(params) {
1013
1041
  * landed within the poll window (`networkIsRealAdvance` false), the button works
1014
1042
  * but the wizard is refusing to advance (a precondition isn't met, e.g. a
1015
1043
  * required field the flow answers on a LATER step). Re-clicking the same button
1016
- * only re-fires the autosave / a `back` bounce — measured across HCA runs as the
1044
+ * only re-fires the autosave / a `back` bounce — measured across wizard-ATS runs as the
1017
1045
  * next→back oscillation. Break to replan instead, which can reorder a later step
1018
1046
  * forward. Conservative: any condition unmet → run the full cascade as before.
1019
1047
  * Never fires on final/submit steps (they own `isSubmitRevealedInvalid`) or on
@@ -1066,7 +1094,7 @@ async function rephraseWithLLM(client, originalStep, triedSelectors, observeCand
1066
1094
  * invalid form fields and visible error messages so the rephrase LLM can
1067
1095
  * propose corrective fills instead of
1068
1096
  * just "click harder" — the previous limitation observed in the
1069
- * telemetry of the appcast Encompass run (every rephrase converged on
1097
+ * telemetry of the JSON-envelope ATS run (every rephrase converged on
1070
1098
  * "Click Submit Application using JavaScript" because the prompt had no
1071
1099
  * signal that the form was invalid).
1072
1100
  */
@@ -1411,8 +1439,8 @@ const BODY_EXCERPT_FORM_WINDOW = 16_000;
1411
1439
  * Default: first 8KB. That window held for tenants whose form was at the
1412
1440
  * top of the page (early sweeps in 2026-06).
1413
1441
  *
1414
- * For pages where the form HTML lives below 8KB (verified on AppCast's
1415
- * applyboard SPA: ng-invalid first appears at byte ~15,500 after a header
1442
+ * For pages where the form HTML lives below 8KB (verified on an Angular
1443
+ * apply SPA: ng-invalid first appears at byte ~15,500 after a header
1416
1444
  * of Angular hydration JS + chrome), the 8KB cap silently produced
1417
1445
  * "FORM FIELDS CURRENTLY MARKED INVALID: (none)" in the replan prompt,
1418
1446
  * leaving the LLM with no evidence and causing it to hallucinate steps
@@ -1423,7 +1451,7 @@ const BODY_EXCERPT_FORM_WINDOW = 16_000;
1423
1451
  *
1424
1452
  * Site-agnostic: the markers we look for (ng-invalid, mat-form-field-
1425
1453
  * invalid, is-invalid, <form) are framework-level CSS-class conventions
1426
- * used across countless SPAs, not AppCast-specific.
1454
+ * used across countless SPAs, not specific to any one ATS.
1427
1455
  */
1428
1456
  function selectBodyExcerpt(body) {
1429
1457
  if (body.length <= BODY_EXCERPT_DEFAULT_CAP)
@@ -1454,7 +1482,7 @@ async function extractLivePageFormEvidence(page, options) {
1454
1482
  }
1455
1483
  // Pick a body excerpt that's likely to contain the form's invalid-field
1456
1484
  // markers. Default 8KB cap unless the body has ng-invalid / <form past
1457
- // the cap (typical of AppCast applyboard SPA, where the form starts
1485
+ // the cap (typical of an Angular apply SPA, where the form starts
1458
1486
  // ~15KB into a page of Angular hydration scaffolding). See
1459
1487
  // selectBodyExcerpt for details.
1460
1488
  const bodyExcerpt = selectBodyExcerpt(body);
@@ -1520,7 +1548,7 @@ async function extractLivePageFormEvidence(page, options) {
1520
1548
  * matches `ng-invalid` because of bubbling, but its child `<app-input
1521
1549
  * class="ng-invalid">` ALSO matches; `:not(:has(...))` filters out the parent.
1522
1550
  *
1523
- * Today's Encompass-Fitchburg smoke (run 1781478440322) showed E1's prompt
1551
+ * A JSON-envelope ATS tenant's smoke (run 1781478440322) showed E1's prompt
1524
1552
  * instruction ("prefer the leaf, not the bubbled parent") only got Haiku from
1525
1553
  * 5 wrong fields → 1 wrong field — still surfaced `(unlabeled) <ol>` instead
1526
1554
  * of `<app-input autocomplete="zip-code">` at byte 95,033. All 3 replans + 4
@@ -1710,8 +1738,9 @@ async function extractInteractiveTargetsNearInvalid(page) {
1710
1738
  /**
1711
1739
  * Scan recent capture files for failed submit-endpoint requests and pull
1712
1740
  * out structured field-level errors from the response body. The cascade
1713
- * already saves every captured request to `CAPTURES_DIR` with its parsed
1714
- * `responseBody`; this helper reads those files back, filters to captures
1741
+ * already saves every captured request to the run's graphql capture dir
1742
+ * (see {@link resolveReconRunDir}) with its parsed `responseBody`; this
1743
+ * helper reads those files back, filters to captures
1715
1744
  * matching the configured submit pattern with status >= 400, and walks
1716
1745
  * common error-shape conventions (`{ errors: [{ field, message }] }`,
1717
1746
  * `{ validation/fieldErrors: { … } }`, `{ message }`).
@@ -1728,7 +1757,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
1728
1757
  * deterministic hostname equality. Empty list / "any-4xx" mode disables
1729
1758
  * the host filter and returns any 4xx in the window.
1730
1759
  */
1731
- ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict") {
1760
+ ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
1732
1761
  if (recentCaptureFilenames.length === 0)
1733
1762
  return "";
1734
1763
  if (mode === "strict" && ownBackendHostnames.length === 0)
@@ -1805,7 +1834,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
1805
1834
  }
1806
1835
  /**
1807
1836
  * Surface Google Analytics Measurement Protocol events captured during a
1808
- * step's attempt window. AppCast (and most GA4-instrumented SPAs) emit
1837
+ * step's attempt window. A JSON-envelope ATS (and most GA4-instrumented SPAs) emits
1809
1838
  * `view_secondPage`, `view_thankYouPage`, `form_submit` and similar events
1810
1839
  * via `https://www.google-analytics.com/g/collect` — the engine already
1811
1840
  * stores these in `recentCaptures[]` but no code reads them. Without
@@ -1819,7 +1848,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
1819
1848
  * Returns a numbered evidence list. Empty string when no GA collect
1820
1849
  * captures are present. Advisory — never load-bearing.
1821
1850
  */
1822
- function extractGaEventEvidence(recentCaptureFilenames, capturesDir = recon_shared_1.CAPTURES_DIR) {
1851
+ function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
1823
1852
  if (recentCaptureFilenames.length === 0)
1824
1853
  return "";
1825
1854
  const records = [];
@@ -2078,9 +2107,9 @@ function harvestFieldErrors(body) {
2078
2107
  return [];
2079
2108
  const out = [];
2080
2109
  const rec = body;
2081
- // Singular `{error: "message"}` shape used by AppCast, Lever, Greenhouse,
2110
+ // Singular `{error: "message"}` shape used by JSON-envelope ATSs, Lever, Greenhouse,
2082
2111
  // and any REST API following the {error:string} terse-error convention.
2083
- // Verified on AppCast Encompass-Fitchburg: /integrated_apply 422 body is
2112
+ // Verified on a JSON-envelope ATS tenant: /integrated_apply 422 body is
2084
2113
  // exactly {"error":"Resume is blank"} — no `errors`, no `message`. Before
2085
2114
  // J', this was caught by neither the array branch below nor the
2086
2115
  // extractSubmitFailureEvidence fallback at line 2028, resulting in
@@ -2157,7 +2186,7 @@ const UPLOAD_NETWORK_POLL_INTERVAL_MS = 250;
2157
2186
  /**
2158
2187
  * How long the upload primitive waits for the async ResumeUpload widget (and its
2159
2188
  * lazily-mounted `<input type=file>`) to render before deciding the input is
2160
- * absent. HCA/Talemetry mounts the MUI/react-dropzone widget ~5s after the
2189
+ * absent. The wizard ATS mounts the MUI/react-dropzone widget ~5s after the
2161
2190
  * wizard lands on the Apply page, so a single probe races that mount and the
2162
2191
  * primitive wrongly falls into the click-to-surface path (or skips). Only
2163
2192
  * reached on `upload:true` steps, so it never slows a page with no upload step.
@@ -2167,7 +2196,7 @@ const UPLOAD_WIDGET_RENDER_ATTEMPTS = 17;
2167
2196
  /**
2168
2197
  * URL substrings that mark a POST as an actual resume/attachment upload rather
2169
2198
  * than coincidental traffic (analytics beacons, `/interruption_check`,
2170
- * geocoders). Talemetry posts the resume to an `attachment_upload_url` that
2199
+ * geocoders). The wizard ATS posts the resume to an `attachment_upload_url` that
2171
2200
  * matches `/attachment`; the others cover the other ATS upload sinks. Module-
2172
2201
  * level so both the raw-input path and the click-to-surface path share one list.
2173
2202
  */
@@ -2206,7 +2235,7 @@ async function waitForUploadNetworkSignal(params) {
2206
2235
  * True when a control's text/aria-label denotes a resume-upload affordance
2207
2236
  * (the button that surfaces a hidden `<input type=file>` or opens a chooser).
2208
2237
  * Pure + exported for unit tests; the vocabulary is intentionally generic so it
2209
- * benefits any MUI/React/dropzone ATS, not just Talemetry. Rejects negative
2238
+ * benefits any MUI/React/dropzone ATS, not just the wizard ATS. Rejects negative
2210
2239
  * lookalikes ("upload later", "no file", a bare "submit") so the click-to-
2211
2240
  * surface path never fires a skip/decline/submit control.
2212
2241
  */
@@ -2280,7 +2309,7 @@ async function tryUploadPrimitive(params) {
2280
2309
  return false;
2281
2310
  }
2282
2311
  if ((inputCount ?? 0) === 0) {
2283
- // Talemetry/MUI and other react-dropzone widgets can render NO <input type=file>
2312
+ // MUI wizard and other react-dropzone widgets can render NO <input type=file>
2284
2313
  // at all (a click surfaces it, or a native chooser opens). Try to surface it
2285
2314
  // (click-to-mount or CDP native-chooser interception) before giving up.
2286
2315
  logger.info("upload primitive: no <input type=file> after render wait; attempting click-to-surface");
@@ -2322,7 +2351,7 @@ async function attachToSurfacedInput(params) {
2322
2351
  // ControlValueAccessor binds at the wrapper level — and the wrapper's
2323
2352
  // change handler doesn't observe input.files mutations directly.
2324
2353
  //
2325
- // Verified on AppCast Encompass-Fitchburg today: setInputFiles
2354
+ // Verified on a JSON-envelope ATS tenant: setInputFiles
2326
2355
  // populated input.files but the subsequent /integrated_apply submit
2327
2356
  // had no `apply[resume]` multipart field — the framework wrapper
2328
2357
  // never registered the file in its internal state. Server returned
@@ -2385,7 +2414,7 @@ async function attachToSurfacedInput(params) {
2385
2414
  }
2386
2415
  /**
2387
2416
  * Recover an upload for widgets that mount NO `<input type=file>` until a button
2388
- * is clicked (Talemetry/MUI `ResumeUpload`, react-dropzone). Ordered cheapest-
2417
+ * is clicked (the MUI wizard's `ResumeUpload`, react-dropzone). Ordered cheapest-
2389
2418
  * first: (DZ) a synthetic drag-drop on the dropzone; then, arming CDP native-
2390
2419
  * chooser interception BEFORE any click (a chooser-opening click with no
2391
2420
  * interception blocks the run — the single biggest risk), click the upload
@@ -2572,7 +2601,7 @@ async function setFilesViaCdp(params) {
2572
2601
  * Parse a select/dropdown flow step into the option to choose and (when
2573
2602
  * present) the question label that scopes which dropdown it targets.
2574
2603
  *
2575
- * Why: HCA/Talemetry render dropdowns as `MuiNativeSelect` native `<select>`
2604
+ * Why: the Angular/MUI wizard ATS renders dropdowns as `MuiNativeSelect` native `<select>`
2576
2605
  * with `tabindex="-1"` — removed from the accessibility tree, so Stagehand
2577
2606
  * observe returns `[]` and the cascade can never select an option. The select
2578
2607
  * primitive answers these directly from the DOM, but needs the target option
@@ -2621,7 +2650,7 @@ function parseSelectStep(instruction) {
2621
2650
  * Why this exists (sibling of `parseSelectStep`): `parseSelectStep`
2622
2651
  * deliberately excludes bare radio steps ("a bare 'click the Yes answer' is a
2623
2652
  * radio"), leaving radios with no DOM-direct primitive — they fall to the
2624
- * observe cascade, which on HCA/Talemetry's MUI radio markup resolves the step
2653
+ * observe cascade, which on the wizard ATS's MUI radio markup resolves the step
2625
2654
  * to a wrapper `<div>`/`<span>` (not the `<input type=radio>`) and commits via
2626
2655
  * a bare `el.click()` that never triggers React's controlled-state `onChange`.
2627
2656
  * The field stays `Mui-error` "required", Next no-ops, and the wizard walls at
@@ -2673,7 +2702,7 @@ const PRIMITIVE_ENUMERATE_ATTEMPTS = 5;
2673
2702
  const PRIMITIVE_ENUMERATE_RETRY_MS = 600;
2674
2703
  /**
2675
2704
  * Run a primitive's read-only DOM enumerate with a bounded settle-retry. SPA
2676
- * wizards (Talemetry) frequently render the target widget a beat AFTER the flow
2705
+ * wizards frequently render the target widget a beat AFTER the flow
2677
2706
  * step fires — the first evaluate sees an empty page, so the primitive would
2678
2707
  * give up even though the widget appears moments later. Re-run the enumerate up
2679
2708
  * to `PRIMITIVE_ENUMERATE_ATTEMPTS` times, waiting `PRIMITIVE_ENUMERATE_RETRY_MS`
@@ -2700,7 +2729,7 @@ async function pollEnumerate(page, expr, isPresent, opts) {
2700
2729
  * Bounded poll for the real advance-transition POST to appear in this step's
2701
2730
  * capture window. The verifiers snapshot once after `STEP_PAUSE_MS`, but the
2702
2731
  * genuine `TransitionWorklet(type="next")` POST can land hundreds of ms to 2s+
2703
- * AFTER that snapshot (HCA fires a fast `WorkletPayload` autosave first). A
2732
+ * AFTER that snapshot (the wizard ATS fires a fast `WorkletPayload` autosave first). A
2704
2733
  * one-shot check false-negatives the advance, retries the click, and the stale
2705
2734
  * retry fires a `back` — a next→back oscillation that never leaves the page.
2706
2735
  * Re-check {@link windowHasAdvanceTransition} every `intervalMs` until it matches
@@ -2734,7 +2763,7 @@ async function waitForTransitionBody(params) {
2734
2763
  * Site-agnostic select primitive: answer a native `<select>` dropdown by
2735
2764
  * directly setting its value in the DOM, bypassing Stagehand observe/act.
2736
2765
  *
2737
- * Why this exists (parallels `tryUploadPrimitive`): Talemetry/MUI dropdowns are
2766
+ * Why this exists (parallels `tryUploadPrimitive`): MUI wizard dropdowns are
2738
2767
  * `MuiNativeSelect` native `<select>` elements carrying `tabindex="-1"`, which
2739
2768
  * removes them from the accessibility tree. Stagehand observe returns `[]` for
2740
2769
  * them, so the cascade's `selectOption` (which needs a resolved locator) never
@@ -2764,7 +2793,7 @@ async function waitForTransitionBody(params) {
2764
2793
  * validation tick can be awaited: MUI `NativeSelect` re-runs required-validation
2765
2794
  * a beat after the synthetic `change`, so an immediate `sel.value === value`
2766
2795
  * readback passes even when the FormControl still flags the field required (and
2767
- * a later worklet re-render then wipes the DOM-only value — the exact HCA
2796
+ * a later worklet re-render then wipes the DOM-only value — the exact wizard-ATS
2768
2797
  * Job-Related failure). Returns `stillInvalid` so {@link trySelectPrimitive} can
2769
2798
  * refuse to claim success on an uncommitted select, routing to the cascade/replan
2770
2799
  * instead of silently advancing. Walks ≤6 ancestors for the invalid marker, same
@@ -2963,7 +2992,7 @@ const DECLINE_OPTION_MARKERS = [
2963
2992
  * Pick an option to satisfy a REQUIRED select on a catch-all step, from the
2964
2993
  * select's option TEXTS (placeholder already excluded upstream). Policy: take
2965
2994
  * the first non-decline option (a plausible substantive answer — the operator
2966
- * accepts LLM-plausible answers reaching HCA prod); fall back to the first
2995
+ * accepts LLM-plausible answers reaching ATS prod); fall back to the first
2967
2996
  * option only if every option is a decline/placeholder. Returns null when there
2968
2997
  * is nothing selectable. Pure + exported so the policy is unit-testable; the LLM
2969
2998
  * path ({@link judgeSelectOptionWithLLM}) is preferred when a client is present,
@@ -3110,7 +3139,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
3110
3139
  * question by directly checking the matching option in the DOM, bypassing
3111
3140
  * Stagehand observe/act.
3112
3141
  *
3113
- * Why this exists (parallels `trySelectPrimitive`): HCA/Talemetry render
3142
+ * Why this exists (parallels `trySelectPrimitive`): the Angular/MUI wizard ATS renders
3114
3143
  * multi-select screening questions ("In which settings have you worked…",
3115
3144
  * certifications) as `c-MultiCheckboxInput` groups — a `<fieldset>`/`<legend>`
3116
3145
  * question with `<input type=checkbox>` options, each tied to its text by a
@@ -3278,7 +3307,7 @@ async function tryCheckboxPrimitive(params) {
3278
3307
  }
3279
3308
  /**
3280
3309
  * Build an XPath predicate that matches an `<input>` by its `id`, safe for any
3281
- * id value. MUI/Talemetry radio ids are base64-ish (no double-quote), so a plain
3310
+ * id value. MUI wizard radio ids are base64-ish (no double-quote), so a plain
3282
3311
  * quoted literal suffices — but if an id ever contains a `"`, fall back to
3283
3312
  * `concat(...)` so the XPath stays valid. Pure + exported for unit tests.
3284
3313
  */
@@ -3293,7 +3322,7 @@ function buildRadioIdXPath(id) {
3293
3322
  * positionally. Pure (no DOM/LLM) so it is unit-testable — the crux of the
3294
3323
  * unlabeled-radio disambiguation.
3295
3324
  *
3296
- * Why this exists: HCA/Talemetry Basic Info has multiple UNLABELED yes/no groups
3325
+ * Why this exists: the wizard ATS's Basic Info has multiple UNLABELED yes/no groups
3297
3326
  * (visa-sponsorship, common-domicile), answered by consecutive flow steps. The
3298
3327
  * old in-browser matcher treated an unlabeled group (`label===""`) as matching
3299
3328
  * ANY question (`"".includes(q)`/`q.includes("")===0`), so two unlabeled "No"
@@ -3435,12 +3464,12 @@ function selectRadioGroupOption(params) {
3435
3464
  * observe/act.
3436
3465
  *
3437
3466
  * Why this exists (parallels `trySelectPrimitive`/`tryCheckboxPrimitive`):
3438
- * HCA/Talemetry render eligibility/screening questions as MUI radio groups.
3467
+ * The Angular/MUI wizard ATS renders eligibility/screening questions as MUI radio groups.
3439
3468
  * Stagehand observe resolves the step to a wrapper `<div>`/`<span>`, not the
3440
3469
  * `<input type=radio>`, so the cascade's `el.click()` fallback fires on the
3441
3470
  * wrapper (or sets `.checked` without triggering React's `onChange`) and the
3442
3471
  * controlled value never commits — the field stays `Mui-error` "required", Next
3443
- * no-ops, and the wizard walls (measured on HCA: the "Are you at least 18?"
3472
+ * no-ops, and the wizard walls (measured on the wizard ATS: the "Are you at least 18?"
3444
3473
  * radio was the sole unfilled field blocking Basic Information → Step 2 of 10).
3445
3474
  * This primitive finds the radio group by raw DOM, matches the requested option
3446
3475
  * by its `<label for>` text, and commits via the React-safe native `checked`
@@ -4123,7 +4152,7 @@ const FORM_VALIDITY_PROBE_EXPR = `(() => {
4123
4152
  const leafInvalid = INVALID_CLASS_RX.test(ctrlClass);
4124
4153
  // A <select> whose currently-selected option is .disabled is the
4125
4154
  // Angular "Please select..." placeholder state. Some custom-element
4126
- // dropdowns (e.g. AppCast's app-dropdown) bind a truthy sentinel value
4155
+ // dropdowns (e.g. an Angular apply SPA's app-dropdown) bind a truthy sentinel value
4127
4156
  // like "0: null" to the disabled placeholder, so ctrl.value !== ""
4128
4157
  // even though no real option is chosen. Without this branch the empty
4129
4158
  // check below silently drops every such wrapper and the pre-submit
@@ -4284,8 +4313,23 @@ function narrowInvalidFormControl(entry) {
4284
4313
  autoFilled: narrowedAutoFilled,
4285
4314
  };
4286
4315
  }
4316
+ /**
4317
+ * Single source of truth for step-line prefixes. Exists because the cascade
4318
+ * here has only ever received `stepIndex`, while the orchestrator loop in
4319
+ * recon-browser owns the plan array — so half the step lines in a run printed
4320
+ * a `N/total` denominator and half printed a bare `N`.
4321
+ *
4322
+ * Takes a getter rather than a number: a global replan splices new steps into
4323
+ * the live plan array mid-run, so the total must be read when the line is
4324
+ * emitted, not when the step started. Callers with no total omit it and get
4325
+ * the bare form.
4326
+ */
4327
+ function formatStepPrefix(stepIndex, totalSteps) {
4328
+ const total = totalSteps?.();
4329
+ return total === undefined ? `step ${stepIndex + 1}` : `step ${stepIndex + 1}/${total}`;
4330
+ }
4287
4331
  async function probeFormValidityBeforeSubmit(params) {
4288
- const { page, stepIndex, logger } = params;
4332
+ const { page, stepIndex, totalSteps, logger } = params;
4289
4333
  try {
4290
4334
  const raw = await page.evaluate(FORM_VALIDITY_PROBE_EXPR);
4291
4335
  if (!Array.isArray(raw))
@@ -4299,15 +4343,15 @@ async function probeFormValidityBeforeSubmit(params) {
4299
4343
  }
4300
4344
  const autoCount = out.filter((e) => e.autoFilled !== null).length;
4301
4345
  if (out.length > 0) {
4302
- logger.info(`step ${stepIndex + 1} pre-submit probe: ${out.length} ng-invalid form control(s) detected; empty=${out.filter((e) => e.emptyOrUnchecked).length}; auto-picked=${autoCount}`);
4346
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: ${out.length} ng-invalid form control(s) detected; empty=${out.filter((e) => e.emptyOrUnchecked).length}; auto-picked=${autoCount}`);
4303
4347
  }
4304
4348
  else {
4305
- logger.info(`step ${stepIndex + 1} pre-submit probe: no ng-invalid form controls detected`);
4349
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: no ng-invalid form controls detected`);
4306
4350
  }
4307
4351
  return out;
4308
4352
  }
4309
4353
  catch (err) {
4310
- logger.warn(`step ${stepIndex + 1} pre-submit probe threw: ${(0, errors_1.toErrorMessage)(err)} — proceeding without pre-flight evidence`);
4354
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe threw: ${(0, errors_1.toErrorMessage)(err)} — proceeding without pre-flight evidence`);
4311
4355
  return [];
4312
4356
  }
4313
4357
  }
@@ -4321,14 +4365,14 @@ async function probeFormValidityBeforeSubmit(params) {
4321
4365
  * step is declared "absent". Exported for tests.
4322
4366
  */
4323
4367
  async function probeStepBeforeAttempts(params) {
4324
- const { stagehand, step, stepIndex, logger, captureFn } = params;
4368
+ const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
4325
4369
  try {
4326
4370
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4327
4371
  if (candidates.length === 0) {
4328
4372
  // Focused observe under-returns on some controlled-component forms:
4329
4373
  // Stagehand's instruction-scoped observe can resolve zero candidates for a
4330
4374
  // declarative "Fill in the X field with 'Y'" step even when the field is
4331
- // present and actionable (confirmed on HCA's MUI/React Talemetry form — an
4375
+ // present and actionable (confirmed on the MUI/React wizard ATS's form — an
4332
4376
  // unfocused observe enumerated every field and a direct act filled them).
4333
4377
  // Treating focused-empty as hard "absent" skips the whole cascade and burns
4334
4378
  // a global replan per step. Fall back to an UNFOCUSED observe as a
@@ -4338,24 +4382,24 @@ async function probeStepBeforeAttempts(params) {
4338
4382
  // where the unfocused observe is also empty — stay "absent".
4339
4383
  const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4340
4384
  if (unfocused.length > 0) {
4341
- logger.info(`step ${stepIndex + 1}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4385
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4342
4386
  return "present";
4343
4387
  }
4344
- logger.info(`step ${stepIndex + 1}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
4388
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
4345
4389
  return "absent";
4346
4390
  }
4347
- logger.info(`step ${stepIndex + 1}: probe found ${candidates.length} candidate(s)`);
4391
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found ${candidates.length} candidate(s)`);
4348
4392
  return "present";
4349
4393
  }
4350
4394
  catch (err) {
4351
4395
  // Bias toward the existing behavior on errors: don't trigger a spurious
4352
4396
  // replan when the probe itself is the broken thing.
4353
- logger.warn(`step ${stepIndex + 1}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
4397
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
4354
4398
  return "present";
4355
4399
  }
4356
4400
  }
4357
4401
  async function executeStepWithHealing(params) {
4358
- const { stagehand, page, step, optional, upload, submitStep, stepIndex, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, trajectory, onStepFailure, } = params;
4402
+ const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4359
4403
  // Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
4360
4404
  // through executeStepWithHealing's signature so the cascade has it in
4361
4405
  // scope when the invalid-fields judge migration (Task #43) lands. The
@@ -4371,12 +4415,12 @@ async function executeStepWithHealing(params) {
4371
4415
  // multi-signal evidence with strict prompting instead.
4372
4416
  //
4373
4417
  // Gate accepts (isFinalStep || submitStep): flows whose canonical Submit
4374
- // click lives mid-list (e.g. AppCast's flow has Submit at step 55/328 with
4418
+ // click lives mid-list (e.g. one JSON-envelope ATS flow has Submit at step 55/328 with
4375
4419
  // 273 post-submit verification steps) opt in via the per-step
4376
4420
  // `submitStep: true` flag in the flow file. Without this opt-in, the pre-
4377
4421
  // submit DOM probe gated solely on `isFinalStep` never fires on the real
4378
4422
  // Submit, and unfilled required fields produce silent submit failures
4379
- // (verified 2026-06-15 on UVA Verona telemetry). Site-agnostic: any flow
4423
+ // (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
4380
4424
  // whose submit is mid-list can mark its submit step explicitly.
4381
4425
  const requireSubmitEndpoint = (isFinalStep || submitStep) && submitEndpointPattern !== null;
4382
4426
  const attempts = [];
@@ -4395,28 +4439,28 @@ async function executeStepWithHealing(params) {
4395
4439
  signalCounter,
4396
4440
  recentCaptureMeta,
4397
4441
  })) {
4398
- logger.info(`step ${stepIndex + 1} resolved by upload primitive`);
4442
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
4399
4443
  trajectory?.push({ stepIndex, verifiedBy: "network" });
4400
4444
  return "completed";
4401
4445
  }
4402
4446
  // When the step is a native-<select> dropdown selection, answer it directly
4403
4447
  // in the DOM ahead of the cascade. Critical for MuiNativeSelect/tabindex=-1
4404
- // dropdowns (HCA/Talemetry) that Stagehand observe can't surface — the
4448
+ // dropdowns (the MUI wizard ATS) that Stagehand observe can't surface — the
4405
4449
  // cascade would otherwise skip them ("no candidates") and leave a required
4406
4450
  // question unanswered. No-op (returns false → falls through) when the step
4407
4451
  // isn't a single-dropdown select or no option matches.
4408
4452
  if (await trySelectPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4409
- logger.info(`step ${stepIndex + 1} resolved by select primitive`);
4453
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
4410
4454
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4411
4455
  return "completed";
4412
4456
  }
4413
4457
  // When the step is a "select 'X'" against a multi-select CHECKBOX group
4414
- // (c-MultiCheckboxInput) rather than a <select> — Talemetry renders some
4458
+ // (c-MultiCheckboxInput) rather than a <select> — the wizard ATS renders some
4415
4459
  // screening questions this way — answer it directly in the DOM. Runs AFTER
4416
4460
  // trySelectPrimitive (which handles <select> and no-ops on checkbox-only
4417
4461
  // pages). No-op (falls through) when there's no checkbox group or no match.
4418
4462
  if (await tryCheckboxPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4419
- logger.info(`step ${stepIndex + 1} resolved by checkbox primitive`);
4463
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
4420
4464
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4421
4465
  return "completed";
4422
4466
  }
@@ -4424,10 +4468,10 @@ async function executeStepWithHealing(params) {
4424
4468
  // the question '…'"), commit it directly in the DOM. Runs AFTER select/
4425
4469
  // checkbox (which own their verbs) and BEFORE the cascade, so radios never
4426
4470
  // reach the observe cascade's el.click() fallback that fails to commit MUI/
4427
- // React controlled state (the HCA Basic-Info Step-2 wall). No-op (falls
4471
+ // React controlled state (the wizard ATS's Basic-Info Step-2 wall). No-op (falls
4428
4472
  // through) when there's no radio group or no confident option match.
4429
4473
  if (await tryRadioPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4430
- logger.info(`step ${stepIndex + 1} resolved by radio primitive`);
4474
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
4431
4475
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4432
4476
  return "completed";
4433
4477
  }
@@ -4438,7 +4482,7 @@ async function executeStepWithHealing(params) {
4438
4482
  // catch-all (parseSelectStep returns null there, so trySelectPrimitive above
4439
4483
  // skipped it) and no-ops when the page has no required-empty select.
4440
4484
  if (await tryFillRequiredSelectsPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4441
- logger.info(`step ${stepIndex + 1} resolved by required-select primitive`);
4485
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
4442
4486
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4443
4487
  return "completed";
4444
4488
  }
@@ -4454,6 +4498,7 @@ async function executeStepWithHealing(params) {
4454
4498
  stagehand,
4455
4499
  step,
4456
4500
  stepIndex,
4501
+ totalSteps,
4457
4502
  logger,
4458
4503
  captureFn,
4459
4504
  });
@@ -4464,10 +4509,10 @@ async function executeStepWithHealing(params) {
4464
4509
  // can't resolve the widget) — skipping would leave a required field empty
4465
4510
  // and silently doom the later submit. Fall through to the cascade instead.
4466
4511
  if (await hasUnfilledRequiredControlForStep(page, step)) {
4467
- logger.info(`step ${stepIndex + 1} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
4512
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
4468
4513
  }
4469
4514
  else {
4470
- logger.info(`step ${stepIndex + 1} skipped (optional, probe found no candidates)`);
4515
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, probe found no candidates)`);
4471
4516
  return "skipped";
4472
4517
  }
4473
4518
  }
@@ -4482,7 +4527,7 @@ async function executeStepWithHealing(params) {
4482
4527
  preMetaLength: stepStartMetaLength,
4483
4528
  });
4484
4529
  if (transitionUrl !== null) {
4485
- logger.info(`step ${stepIndex + 1} skipped (probe absent but recent transition detected: ${transitionUrl})`);
4530
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (probe absent but recent transition detected: ${transitionUrl})`);
4486
4531
  trajectory?.push({ stepIndex, verifiedBy: "url" });
4487
4532
  return "completed";
4488
4533
  }
@@ -4497,8 +4542,8 @@ async function executeStepWithHealing(params) {
4497
4542
  ownBackendHostnames,
4498
4543
  });
4499
4544
  if (backendErrorUrl !== null) {
4500
- logger.error(`step ${stepIndex + 1} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
4501
- throw new errors_2.StepVerificationError(`step ${stepIndex + 1} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
4545
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
4546
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
4502
4547
  }
4503
4548
  // Capture diagnostics + write a failure dump BEFORE throwing so the
4504
4549
  // global replan path's `readFailureDumpEvidence` can populate the
@@ -4526,7 +4571,7 @@ async function executeStepWithHealing(params) {
4526
4571
  bodyOuterHtml,
4527
4572
  unfocusedObserve,
4528
4573
  }) ?? null;
4529
- throw new errors_2.StepVerificationError(`step ${stepIndex + 1} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
4574
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
4530
4575
  }
4531
4576
  // Pre-submit form-validity probe. Fires on the canonical submit step
4532
4577
  // (either the final flow step OR a step explicitly flagged `submitStep:
@@ -4551,6 +4596,7 @@ async function executeStepWithHealing(params) {
4551
4596
  const invalidControls = await probeFormValidityBeforeSubmit({
4552
4597
  page,
4553
4598
  stepIndex,
4599
+ totalSteps,
4554
4600
  logger,
4555
4601
  });
4556
4602
  for (const c of invalidControls) {
@@ -4592,6 +4638,7 @@ async function executeStepWithHealing(params) {
4592
4638
  resolvedMethod: null,
4593
4639
  resolvedArguments: null,
4594
4640
  verifiedBy: null,
4641
+ phantomClickVerdict: null,
4595
4642
  });
4596
4643
  }
4597
4644
  // Brief settle window after auto-picks so Angular's change-detection
@@ -4607,6 +4654,11 @@ async function executeStepWithHealing(params) {
4607
4654
  // NOT move the wizard forward. Read at the top of attempts 2-4 to skip the
4608
4655
  // proven-dead re-observe/re-click techniques (see shouldSkipTechnique).
4609
4656
  let advanceUnmovedAfterAttempt1 = false;
4657
+ // Set after attempt 1: Stagehand reported success but pre/post shows zero
4658
+ // observable effect (see classifyPhantomClick). Reroutes attempt 2 to
4659
+ // deep-submit-locator instead of observe-act, since re-resolving via the
4660
+ // same light-DOM view cannot reach a target the resolver can't see.
4661
+ let phantomClickAfterAttempt1 = false;
4610
4662
  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
4611
4663
  // Telemetry-driven technique-skip: when a cascade technique's
4612
4664
  // preconditions cannot be met by the prior attempts' state, running
@@ -4615,7 +4667,9 @@ async function executeStepWithHealing(params) {
4615
4667
  // techniques faster.
4616
4668
  if (attempt > 1) {
4617
4669
  const wouldBeTechnique = attempt === 2
4618
- ? "observe-act"
4670
+ ? phantomClickAfterAttempt1 && (isFinalStep || submitStep)
4671
+ ? "deep-submit-locator"
4672
+ : "observe-act"
4619
4673
  : attempt === 3
4620
4674
  ? "structured-click"
4621
4675
  : attempt === 4
@@ -4629,9 +4683,11 @@ async function executeStepWithHealing(params) {
4629
4683
  errorMessage: a.errorMessage,
4630
4684
  })),
4631
4685
  advanceUnmovedAfterAttempt1,
4686
+ phantomClickAfterAttempt1,
4687
+ submitShapedStep: isFinalStep || submitStep,
4632
4688
  });
4633
4689
  if (decision.skip) {
4634
- logger.info(`step ${stepIndex + 1} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
4690
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
4635
4691
  failureReasons.push(`attempt ${attempt} skipped: ${decision.reason}`);
4636
4692
  continue;
4637
4693
  }
@@ -4662,6 +4718,7 @@ async function executeStepWithHealing(params) {
4662
4718
  resolvedMethod: null,
4663
4719
  resolvedArguments: null,
4664
4720
  verifiedBy: null,
4721
+ phantomClickVerdict: null,
4665
4722
  };
4666
4723
  // First resolved action from Stagehand's `act` result — used to decide
4667
4724
  // whether this attempt's signal should come from the network/URL pair or
@@ -4698,6 +4755,124 @@ async function executeStepWithHealing(params) {
4698
4755
  }
4699
4756
  }
4700
4757
  }
4758
+ else if (attempt === 2 && phantomClickAfterAttempt1 && (isFinalStep || submitStep)) {
4759
+ // Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
4760
+ // reported success but pre/post showed zero effect), so the target is
4761
+ // almost certainly unreachable via document.querySelectorAll — most
4762
+ // likely rendered inside an open shadow root by a web-component /
4763
+ // framework-native submit control (see recon-submit-phantom-click bug
4764
+ // report). Rank every submit-shaped candidate the deep traversal can
4765
+ // reach and click the top-ranked one; the ranking already excludes
4766
+ // Back/Cancel/Save-draft-shaped controls, so a false-positive submit
4767
+ // click cannot fire.
4768
+ //
4769
+ // Rank and click are two separate page.evaluate round trips over a
4770
+ // live Angular page, so any re-render between them shifts every
4771
+ // deepIndex — the click then lands on nothing (`{clicked: false}`),
4772
+ // not because the page is broken but because the snapshot the index
4773
+ // was computed from is already gone. That's a transient, self-clearing
4774
+ // condition, so re-rank against the CURRENT DOM and click once more
4775
+ // before giving up. Capped at one retry (two rank+click rounds total)
4776
+ // so a page re-rendering on every tick can't turn this into a loop.
4777
+ record.technique = "deep-submit-locator";
4778
+ for (let deepAttempt = 1; deepAttempt <= 2; deepAttempt++) {
4779
+ let ranked;
4780
+ try {
4781
+ ranked = (await page.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
4782
+ }
4783
+ catch (err) {
4784
+ // A thrown evaluate (page navigated away / frame detached) is not
4785
+ // a stale-index race — re-ranking a detached page will throw
4786
+ // again, so don't retry; record it and let the cascade move on.
4787
+ record.errorMessage = `deep-submit-locator: rank evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
4788
+ break;
4789
+ }
4790
+ if (ranked.length === 0) {
4791
+ record.errorMessage = "deep-submit-locator: no submit-shaped candidate found";
4792
+ break;
4793
+ }
4794
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
4795
+ const top = ranked[0];
4796
+ record.instruction = `deep-submit-locator: ${top.tag} "${top.accessibleName}" (tier ${top.tier})`;
4797
+ record.triedSelectors = [`deep-index:${top.deepIndex}`];
4798
+ triedSelectors.push(`deep-index:${top.deepIndex}`);
4799
+ let clickResult;
4800
+ try {
4801
+ clickResult = (await page.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
4802
+ }
4803
+ catch (err) {
4804
+ record.errorMessage = `deep-submit-locator: click evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
4805
+ break;
4806
+ }
4807
+ record.actResultSuccess = clickResult.clicked;
4808
+ record.actResultDescription = clickResult.clicked
4809
+ ? `deep-submit-locator clicked ${top.tag} "${top.accessibleName}"`
4810
+ : "deep-submit-locator: candidate vanished before click (deepIndex stale)";
4811
+ if (clickResult.clicked) {
4812
+ // Synthesize a click action so downstream verification (network /
4813
+ // url / dom) treats this exactly like any other resolved click.
4814
+ resolvedAction = {
4815
+ selector: `deep-index:${top.deepIndex}`,
4816
+ description: record.actResultDescription,
4817
+ method: "click",
4818
+ };
4819
+ // Runner-up retry: the top pick can itself phantom-click (a
4820
+ // second web-component / shadow-root control that LOOKS
4821
+ // submit-shaped but doesn't wire a real handler — see
4822
+ // submit-control.ts's module docblock). Re-snapshot right here
4823
+ // and re-classify with the same classifyPhantomClick primitive
4824
+ // that escalated attempt 1, so a phantom top pick doesn't fall
4825
+ // through to attempt 3's structured-click, which can't reach a
4826
+ // shadow-root control either. Cap at ranked[1] only (not the
4827
+ // full list) so a page with many submit-shaped candidates can't
4828
+ // burn the step budget probing all of them.
4829
+ const runnerUp = ranked[1];
4830
+ if (runnerUp) {
4831
+ const midPost = await snapshotPage(page, signalCounter);
4832
+ const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
4833
+ actResultSuccess: true,
4834
+ pre,
4835
+ post: midPost,
4836
+ });
4837
+ if (topVerdict === "phantom") {
4838
+ 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})`);
4839
+ record.instruction = `deep-submit-locator: ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier}), runner-up after top pick ${top.tag} "${top.accessibleName}" phantomed`;
4840
+ record.triedSelectors = [
4841
+ `deep-index:${top.deepIndex}`,
4842
+ `deep-index:${runnerUp.deepIndex}`,
4843
+ ];
4844
+ triedSelectors.push(`deep-index:${runnerUp.deepIndex}`);
4845
+ const runnerUpClickResult = (await page
4846
+ .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
4847
+ .catch(() => ({ clicked: false })));
4848
+ record.actResultSuccess = runnerUpClickResult.clicked;
4849
+ record.actResultDescription = runnerUpClickResult.clicked
4850
+ ? `deep-submit-locator clicked runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" after top pick phantomed`
4851
+ : "deep-submit-locator: runner-up candidate vanished before click (deepIndex stale)";
4852
+ resolvedAction = runnerUpClickResult.clicked
4853
+ ? {
4854
+ selector: `deep-index:${runnerUp.deepIndex}`,
4855
+ description: record.actResultDescription,
4856
+ method: "click",
4857
+ }
4858
+ : null;
4859
+ if (!runnerUpClickResult.clicked) {
4860
+ record.errorMessage = record.actResultDescription;
4861
+ }
4862
+ }
4863
+ }
4864
+ // The top pick (or its runner-up) was reached and clicked. A
4865
+ // runner-up that itself vanished is not the stale-index race the
4866
+ // re-rank exists for — the rank was fresh enough to click the top
4867
+ // pick — so exit either way and let the cascade classify.
4868
+ break;
4869
+ }
4870
+ record.errorMessage = record.actResultDescription;
4871
+ if (deepAttempt === 1) {
4872
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator: deepIndex stale on first click, re-ranking once`);
4873
+ }
4874
+ }
4875
+ }
4701
4876
  else if (attempt === 2 || attempt === 4) {
4702
4877
  record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
4703
4878
  const observeOptions = attempt === 4 && triedSelectors.length > 0
@@ -4725,12 +4900,12 @@ async function executeStepWithHealing(params) {
4725
4900
  // don't fast-skip — let the healing cascade continue so the
4726
4901
  // required field gets answered instead of silently doomed.
4727
4902
  if (await hasUnfilledRequiredControlForStep(page, step)) {
4728
- logger.info(`step ${stepIndex + 1} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
4903
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
4729
4904
  }
4730
4905
  else {
4731
4906
  record.verifiedBy = null;
4732
4907
  attempts.push(record);
4733
- logger.info(`step ${stepIndex + 1} skipped (optional, no candidates after act+observe)`);
4908
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, no candidates after act+observe)`);
4734
4909
  return "skipped";
4735
4910
  }
4736
4911
  }
@@ -4748,7 +4923,7 @@ async function executeStepWithHealing(params) {
4748
4923
  record.triedSelectors = [target.selector];
4749
4924
  attempts.push(record);
4750
4925
  failureReasons.push(record.errorMessage);
4751
- logger.info(`step ${stepIndex + 1} attempt ${attempt}: ${record.errorMessage}`);
4926
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
4752
4927
  continue;
4753
4928
  }
4754
4929
  record.instruction = target.description;
@@ -4808,7 +4983,7 @@ async function executeStepWithHealing(params) {
4808
4983
  record.actResultSuccess = false;
4809
4984
  }
4810
4985
  else if (readback.outcome === "differs") {
4811
- logger.info(`step ${stepIndex + 1} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
4986
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
4812
4987
  }
4813
4988
  }
4814
4989
  }
@@ -4824,7 +4999,7 @@ async function executeStepWithHealing(params) {
4824
4999
  // browser's default label-click action does NOT toggle .checked for
4825
5000
  // hidden inputs (Bootstrap, Tailwind, plain HTML labels-control,
4826
5001
  // Angular Material's mat-radio-button, PrimeNG's p-radioButton, and
4827
- // custom Angular components like AppCast's app-radio-button all fit
5002
+ // custom Angular components like an apply SPA's app-radio-button all fit
4828
5003
  // this shape). The framework's FormControl/state listens on the
4829
5004
  // input's `change` event, which never fires when the visible click
4830
5005
  // never reaches a clickable input — so the form stays invalid even
@@ -5053,7 +5228,7 @@ async function executeStepWithHealing(params) {
5053
5228
  ? await verifyDomEffect(page, resolvedAction)
5054
5229
  : false;
5055
5230
  // Interior-advance transition gate (opt-in). On SPAs where a page advance
5056
- // and a mere field-edit share one endpoint URL (Talemetry `/gq`:
5231
+ // and a mere field-edit share one endpoint URL (the wizard ATS's `/gq`:
5057
5232
  // TransitionWorklet vs EditQuestionItem — identical URLs, only the body
5058
5233
  // differs), a `networkFired` signal on an interior "Next" can be a
5059
5234
  // non-advancing POST. When the flow configures `advanceTransitionBodyPattern`
@@ -5080,7 +5255,7 @@ async function executeStepWithHealing(params) {
5080
5255
  intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
5081
5256
  });
5082
5257
  if (advanceGateActive && !networkIsRealAdvance) {
5083
- logger.info(`step ${stepIndex + 1} network fired but no advance-transition (type=next) body matched within ${ADVANCE_TRANSITION_POLL_MS}ms poll (non-advancing POST); not treating as verified`);
5258
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} network fired but no advance-transition (type=next) body matched within ${ADVANCE_TRANSITION_POLL_MS}ms poll (non-advancing POST); not treating as verified`);
5084
5259
  }
5085
5260
  // DOM-only-advance veto (opt-in). A rephrase can turn an advance/"Next" step
5086
5261
  // into a field click (e.g. "click the Yes radio for '18?' then Next"), which
@@ -5089,7 +5264,7 @@ async function executeStepWithHealing(params) {
5089
5264
  // instruction) whose ONLY signal is that DOM state change, do NOT count it
5090
5265
  // verified: an advance requires a REAL transition (a `type=next` per
5091
5266
  // `networkIsRealAdvance`, or a URL change). Keyed on `networkIsRealAdvance`,
5092
- // NOT `!networkFired` — a non-advancing autosave POST (Talemetry
5267
+ // NOT `!networkFired` — a non-advancing autosave POST (the wizard ATS's
5093
5268
  // `WorkletPayload`) fires network=true while the wizard doesn't move, and the
5094
5269
  // old `!networkFired` guard let a rephrase ride that autosave + a DOM reflow
5095
5270
  // to a FALSE advance, desyncing the flow from the wizard. Field-answer steps
@@ -5106,14 +5281,14 @@ async function executeStepWithHealing(params) {
5106
5281
  ? domVerified
5107
5282
  : false;
5108
5283
  if (domVerified && !domVerifiedForStep) {
5109
- logger.info(`step ${stepIndex + 1} advance step succeeded only via DOM state change (field toggle / non-advancing POST), not a real transition; not treating as verified`);
5284
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} advance step succeeded only via DOM state change (field toggle / non-advancing POST), not a real transition; not treating as verified`);
5110
5285
  }
5111
5286
  let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep;
5112
5287
  // Final-step submit-verification gate. Replaces the deterministic
5113
5288
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
5114
5289
  // multi-signal evidence (network captures, page URL/title, DOM
5115
5290
  // submitted-state probe, site-supplied criteria). The regex
5116
- // mislabeled successful submits as failures whenever AppCast used a
5291
+ // mislabeled successful submits as failures whenever an ATS used a
5117
5292
  // POST URL outside the regex's narrow expectation — empirically
5118
5293
  // observed in last night's sweep and validated 2026-06-11 against
5119
5294
  // claude-haiku-4-5-20251001 with the SUBMIT_VERDICT_SCHEMA.
@@ -5208,7 +5383,7 @@ async function executeStepWithHealing(params) {
5208
5383
  // Any-4xx fallback. The judge said failed; surface any captured 4xx
5209
5384
  // body's field-level error JSON for the rephrase prompt downstream
5210
5385
  // — same role as before the migration.
5211
- const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], recon_shared_1.CAPTURES_DIR, "any-4xx");
5386
+ const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], (0, recon_shared_1.resolveReconRunDir)().graphqlDir, "any-4xx");
5212
5387
  if (fallbackEvidence.length > 0) {
5213
5388
  failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
5214
5389
  }
@@ -5312,7 +5487,7 @@ async function executeStepWithHealing(params) {
5312
5487
  // change AND the page still has required-invalid controls (MUI-aware
5313
5488
  // via countNgInvalidContainers), treat the DOM-only signal as NOT
5314
5489
  // verifying so the cascade routes to the fill-invalid-fields replan.
5315
- // Confirmed no-op signature on HCA COMPENSATION (network=false
5490
+ // Confirmed no-op signature on the wizard ATS's COMPENSATION page (network=false
5316
5491
  // url=false htmlDelta>0 textChanged) mis-scored verified=true(dom).
5317
5492
  const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
5318
5493
  const clickBlockedByInvalid = clickWasDomOnly && (await countNgInvalidContainers(page).catch(() => 0)) > 0;
@@ -5326,7 +5501,7 @@ async function executeStepWithHealing(params) {
5326
5501
  // `advanceTransitionBodyPattern`. Without this, a non-advancing POST
5327
5502
  // (retryNetworkFired=true) both satisfied retryVerified AND disarmed the
5328
5503
  // old !retryNetworkFired-gated veto, so the fallback rode past a
5329
- // validation-blocked Next while the page stayed put (HCA Basic Info
5504
+ // validation-blocked Next while the page stayed put (the wizard ATS's Basic Info
5330
5505
  // stage1d). Only arms when the site opted into the pattern; field-answer
5331
5506
  // (non-advance) steps keep their checkboxStateVerified/DOM path.
5332
5507
  // Poll for the real TransitionWorklet(type="next") like the primary
@@ -5348,7 +5523,7 @@ async function executeStepWithHealing(params) {
5348
5523
  retryNetworkIsRealAdvance,
5349
5524
  });
5350
5525
  if (fallbackDomOnlyAdvance) {
5351
- logger.info(`step ${stepIndex + 1} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
5526
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
5352
5527
  }
5353
5528
  let retryVerified = !clickBlockedByInvalid &&
5354
5529
  !fallbackDomOnlyAdvance &&
@@ -5426,7 +5601,7 @@ async function executeStepWithHealing(params) {
5426
5601
  failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
5427
5602
  }
5428
5603
  }
5429
- logger.info(`n+16 probe: step=${stepIndex + 1} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
5604
+ logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
5430
5605
  if (retryVerified) {
5431
5606
  if (record.verifiedBy === null) {
5432
5607
  record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
@@ -5434,46 +5609,56 @@ async function executeStepWithHealing(params) {
5434
5609
  record.post = retryPost;
5435
5610
  attempts.push(record);
5436
5611
  if (attempt > 1) {
5437
- logger.info(`step ${stepIndex + 1} healed on attempt ${attempt} via ${record.technique} + el.click() fallback`);
5612
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} + el.click() fallback`);
5438
5613
  }
5439
5614
  else {
5440
- logger.info(`step ${stepIndex + 1} succeeded on attempt 1 via ${record.technique} + el.click() fallback`);
5615
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} + el.click() fallback`);
5441
5616
  }
5442
5617
  trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
5443
5618
  return "completed";
5444
5619
  }
5445
5620
  }
5446
5621
  catch (probeErr) {
5447
- logger.warn(`n+16 probe: step=${stepIndex + 1} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
5622
+ logger.warn(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
5448
5623
  }
5449
5624
  }
5450
5625
  }
5451
5626
  attempts.push(record);
5452
5627
  if (verified) {
5453
5628
  if (attempt > 1) {
5454
- logger.info(`step ${stepIndex + 1} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5629
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5455
5630
  }
5456
5631
  else {
5457
5632
  // Why log first-try wins explicitly: prior to this change, attempt-1
5458
5633
  // successes were silent — only attempts 2+ emitted "healed on attempt
5459
5634
  // N" lines. That under-reporting caused a 2026-06-15 telemetry-vs-log
5460
- // contradiction where UVA Verona's run looked like a "cascade
5635
+ // contradiction where one measured tenant's run looked like a "cascade
5461
5636
  // collapse" (log showed 2 heals) but telemetry calls.ndjson showed
5462
5637
  // 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
5463
5638
  // log match telemetry and prevents the same false alarm.
5464
- logger.info(`step ${stepIndex + 1} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5639
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5465
5640
  }
5466
5641
  trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
5467
5642
  return "completed";
5468
5643
  }
5469
5644
  const effectSignals = describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLength);
5645
+ // Phantom-click verdict, computed from the SAME pre/post pair
5646
+ // describeAttemptEffectSignals just rendered — not recomputed deltas.
5647
+ // Recorded on every unverified attempt (not just attempt 1) so the
5648
+ // failure dump's attempts[] always carries the classification; only
5649
+ // attempt 1's verdict drives the escalation flag below.
5650
+ record.phantomClickVerdict = (0, phantom_click_1.classifyPhantomClick)({
5651
+ actResultSuccess: record.actResultSuccess,
5652
+ pre,
5653
+ post,
5654
+ });
5470
5655
  const reason = record.errorMessage
5471
5656
  ? effectSignals
5472
5657
  ? `${record.errorMessage}; ${effectSignals}`
5473
5658
  : record.errorMessage
5474
5659
  : effectSignals || "no observable effect (no network, url, or dom change)";
5475
5660
  failureReasons.push(reason);
5476
- logger.warn(`step ${stepIndex + 1} attempt ${attempt} (${record.technique}) produced no observable effect — ${reason}`);
5661
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${record.technique}) produced no observable effect — ${reason}`);
5477
5662
  // One additive strategy among many: when a click on a final-step
5478
5663
  // submit/continue button fails AND the page surfaces a visible
5479
5664
  // <error> sibling next to a touched+dirty ng-invalid wrapper, surface
@@ -5481,7 +5666,7 @@ async function executeStepWithHealing(params) {
5481
5666
  // outcome="impossible" instead of looping on the same plan. Silent
5482
5667
  // no-op on sites where the DOM pattern doesn't match — the existing
5483
5668
  // failure-reason and replan/cycle-detection paths still fire.
5484
- // Empirically grounded: 22 of 22 AppCast Continue/Submit step-failure
5669
+ // Empirically grounded: 22 of 22 JSON-envelope ATS Continue/Submit step-failure
5485
5670
  // dumps in a 2026-06-10 survey had the paired touched+dirty + visible
5486
5671
  // error text pattern with 3 distinct rejection messages.
5487
5672
  if (record.resolvedMethod === "click" && (isFinalStep || submitStep)) {
@@ -5498,7 +5683,7 @@ async function executeStepWithHealing(params) {
5498
5683
  for (const p of pairs) {
5499
5684
  const validationReason = formatValidationRejectedReason(p);
5500
5685
  failureReasons.push(validationReason);
5501
- logger.warn(`step ${stepIndex + 1} ${validationReason}`);
5686
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${validationReason}`);
5502
5687
  }
5503
5688
  }
5504
5689
  // Telemetry-driven early-exit: when attempt 1 on a final-Submit click
@@ -5520,6 +5705,20 @@ async function executeStepWithHealing(params) {
5520
5705
  isAdvanceStep(step) &&
5521
5706
  !urlChanged &&
5522
5707
  !networkIsRealAdvance;
5708
+ // Attempt 1 phantom-clicked: Stagehand reported success but pre/post
5709
+ // shows zero observable effect. The zero-effect delta is the primary
5710
+ // signal (classifyPhantomClick above); the live AISDK elementId
5711
+ // suppression counter is corroborating evidence only — logged, never
5712
+ // gating, since a nonzero count alone is too weak a signal on a run
5713
+ // that sees dozens of suppressions across hundreds of unrelated steps.
5714
+ phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
5715
+ if (phantomClickAfterAttempt1) {
5716
+ const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
5717
+ const escalationTarget = isFinalStep || submitStep
5718
+ ? "escalating attempt 2 to deep-submit-locator"
5719
+ : "non-submit step — leaving the normal structured-click/observe-act-exclude ladder intact";
5720
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} phantom click detected on attempt 1 (${record.technique}): reported success with no network/url/dom change${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — ${escalationTarget}`);
5721
+ }
5523
5722
  const postAttemptInvalidCount = await countNgInvalidContainers(page);
5524
5723
  const earlyExit = isSubmitRevealedInvalid({
5525
5724
  // Treat the canonical submit click as "final" for this predicate
@@ -5535,13 +5734,13 @@ async function executeStepWithHealing(params) {
5535
5734
  if (earlyExit) {
5536
5735
  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`;
5537
5736
  failureReasons.push(exitReason);
5538
- logger.warn(`step ${stepIndex + 1} ${exitReason}`);
5737
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
5539
5738
  break;
5540
5739
  }
5541
5740
  // Telemetry-driven early-exit for interior ADVANCE steps: if the Next
5542
5741
  // click fired and hit the network but produced no real forward
5543
5742
  // transition, attempts 2-N only re-fire the autosave / bounce the wizard
5544
- // back (the measured HCA next→back oscillation). Route to replan — which
5743
+ // back (the measured wizard-ATS next→back oscillation). Route to replan — which
5545
5744
  // can reorder a later step forward — instead of burning the cascade.
5546
5745
  const advanceStalled = isAdvanceStalled({
5547
5746
  isAdvance: isAdvanceStep(step),
@@ -5555,7 +5754,7 @@ async function executeStepWithHealing(params) {
5555
5754
  if (advanceStalled) {
5556
5755
  const exitReason = `advance-stalled: the Next click fired network but no real transition (type=next) landed within the poll window; attempts 2-${MAX_STEP_ATTEMPTS} would only re-bounce the wizard — routing to replan`;
5557
5756
  failureReasons.push(exitReason);
5558
- logger.warn(`step ${stepIndex + 1} ${exitReason}`);
5757
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
5559
5758
  break;
5560
5759
  }
5561
5760
  }
@@ -5583,9 +5782,9 @@ async function executeStepWithHealing(params) {
5583
5782
  unfocusedObserve,
5584
5783
  }) ?? null;
5585
5784
  if (dumpPath !== null) {
5586
- logger.error(`step ${stepIndex + 1} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
5785
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
5587
5786
  }
5588
- throw new errors_2.StepVerificationError(`step ${stepIndex + 1} (${step.slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, "cascade-exhausted");
5787
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
5589
5788
  }
5590
5789
  /** SPA-readiness gate defaults — mirror the recon CLI's post-navigation wait. */
5591
5790
  const SPA_READINESS_TIMEOUT_MS = 15_000;
@@ -5667,6 +5866,7 @@ async function runHealingFlow(deps) {
5667
5866
  upload: s.upload,
5668
5867
  submitStep: s.submitStep,
5669
5868
  stepIndex: i,
5869
+ totalSteps: () => steps.length,
5670
5870
  phase: "flow",
5671
5871
  signalCounter,
5672
5872
  recentCaptures,