@enricai/barnacle 1.6.0 → 1.6.2

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 (57) hide show
  1. package/README.md +66 -7
  2. package/dist/scraper/cookie-jar.d.ts +19 -0
  3. package/dist/scraper/cookie-jar.d.ts.map +1 -0
  4. package/dist/scraper/cookie-jar.js +43 -0
  5. package/dist/scraper/cookie-jar.js.map +1 -0
  6. package/dist/scraper/deep-query.d.ts +36 -0
  7. package/dist/scraper/deep-query.d.ts.map +1 -0
  8. package/dist/scraper/deep-query.js +87 -0
  9. package/dist/scraper/deep-query.js.map +1 -0
  10. package/dist/scraper/errors.d.ts +11 -2
  11. package/dist/scraper/errors.d.ts.map +1 -1
  12. package/dist/scraper/errors.js +10 -1
  13. package/dist/scraper/errors.js.map +1 -1
  14. package/dist/scraper/flow-runner.d.ts +55 -4
  15. package/dist/scraper/flow-runner.d.ts.map +1 -1
  16. package/dist/scraper/flow-runner.js +259 -50
  17. package/dist/scraper/flow-runner.js.map +1 -1
  18. package/dist/scraper/phantom-click.d.ts +45 -0
  19. package/dist/scraper/phantom-click.d.ts.map +1 -0
  20. package/dist/scraper/phantom-click.js +37 -0
  21. package/dist/scraper/phantom-click.js.map +1 -0
  22. package/dist/scraper/session-browserbase.d.ts +48 -0
  23. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  24. package/dist/scraper/session-browserbase.js +10 -5
  25. package/dist/scraper/session-browserbase.js.map +1 -1
  26. package/dist/scraper/session-shared.d.ts +6 -0
  27. package/dist/scraper/session-shared.d.ts.map +1 -1
  28. package/dist/scraper/session-shared.js.map +1 -1
  29. package/dist/scraper/submit-control.d.ts +51 -0
  30. package/dist/scraper/submit-control.d.ts.map +1 -0
  31. package/dist/scraper/submit-control.js +142 -0
  32. package/dist/scraper/submit-control.js.map +1 -0
  33. package/dist/scripts/recon-browser.d.ts +34 -12
  34. package/dist/scripts/recon-browser.d.ts.map +1 -1
  35. package/dist/scripts/recon-browser.js +78 -33
  36. package/dist/scripts/recon-browser.js.map +1 -1
  37. package/dist/scripts/recon-generate.d.ts +28 -7
  38. package/dist/scripts/recon-generate.d.ts.map +1 -1
  39. package/dist/scripts/recon-generate.js +50 -24
  40. package/dist/scripts/recon-generate.js.map +1 -1
  41. package/dist/scripts/recon-http.d.ts +36 -5
  42. package/dist/scripts/recon-http.d.ts.map +1 -1
  43. package/dist/scripts/recon-http.js +77 -47
  44. package/dist/scripts/recon-http.js.map +1 -1
  45. package/dist/scripts/recon-replay-jobs.d.ts +25 -1
  46. package/dist/scripts/recon-replay-jobs.d.ts.map +1 -1
  47. package/dist/scripts/recon-replay-jobs.js +33 -23
  48. package/dist/scripts/recon-replay-jobs.js.map +1 -1
  49. package/dist/scripts/recon-shared.d.ts +68 -0
  50. package/dist/scripts/recon-shared.d.ts.map +1 -1
  51. package/dist/scripts/recon-shared.js +94 -1
  52. package/dist/scripts/recon-shared.js.map +1 -1
  53. package/dist/scripts/recon-summarize.d.ts +5 -1
  54. package/dist/scripts/recon-summarize.d.ts.map +1 -1
  55. package/dist/scripts/recon-summarize.js +19 -7
  56. package/dist/scripts/recon-summarize.js.map +1 -1
  57. package/package.json +9 -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. */
@@ -126,6 +129,12 @@ function wireSignalCapture(page, params) {
126
129
  // into the capture once, in `onFinished` (the only place a Capture is built).
127
130
  // See mergeResponseHeaders.
128
131
  const extraResponseHeaders = new Map();
132
+ // Cookie (and other extra-info-only request headers) keyed by requestId.
133
+ // Mirrors extraResponseHeaders: Network.requestWillBeSent omits the outgoing
134
+ // Cookie header by design, and requestWillBeSentExtraInfo — which carries it —
135
+ // can race requestWillBeSent in either order. Buffered here and folded into
136
+ // the capture once, in `onFinished`. See mergeResponseHeaders.
137
+ const extraRequestHeaders = new Map();
129
138
  // One-shot per-run warning state for the GA `ep.isExpired=true` beacon.
130
139
  // Defensive instrumentation: across 5,542 captures surveyed 2026-06-15,
131
140
  // every observation was `false` — but if a future job ever ships in the
@@ -158,6 +167,13 @@ function wireSignalCapture(page, params) {
158
167
  const merged = mergeResponseHeaders(extraResponseHeaders.get(params.requestId) ?? {}, params.headers);
159
168
  extraResponseHeaders.set(params.requestId, merged);
160
169
  };
170
+ const onRequestExtraInfo = (params) => {
171
+ // Accumulate — can race requestWillBeSent in either order, and a redirect
172
+ // fires this more than once per requestId. The fold into the capture
173
+ // happens once, in onFinished, so order with requestWillBeSent never matters.
174
+ const merged = mergeResponseHeaders(extraRequestHeaders.get(params.requestId) ?? {}, params.headers);
175
+ extraRequestHeaders.set(params.requestId, merged);
176
+ };
161
177
  const onFinished = async (params) => {
162
178
  const req = inFlight.get(params.requestId);
163
179
  if (!req)
@@ -168,6 +184,10 @@ function wireSignalCapture(page, params) {
168
184
  // it can't leak per run.
169
185
  req.responseHeaders = mergeResponseHeaders(req.responseHeaders, extraResponseHeaders.get(params.requestId));
170
186
  extraResponseHeaders.delete(params.requestId);
187
+ // Same fold, request side: recovers the outgoing Cookie header that
188
+ // requestWillBeSent omits by design.
189
+ req.requestHeaders = mergeResponseHeaders(req.requestHeaders, extraRequestHeaders.get(params.requestId));
190
+ extraRequestHeaders.delete(params.requestId);
171
191
  const phase = getCurrentPhase();
172
192
  let responseBody = null;
173
193
  try {
@@ -298,11 +318,13 @@ function wireSignalCapture(page, params) {
298
318
  logger.info(`captured [${capture.status}] ${capture.method} ${req.url} → ${filename}`);
299
319
  };
300
320
  session.on("Network.requestWillBeSent", onRequest);
321
+ session.on("Network.requestWillBeSentExtraInfo", onRequestExtraInfo);
301
322
  session.on("Network.responseReceived", onResponse);
302
323
  session.on("Network.responseReceivedExtraInfo", onResponseExtraInfo);
303
324
  session.on("Network.loadingFinished", onFinished);
304
325
  return () => {
305
326
  session.off("Network.requestWillBeSent", onRequest);
327
+ session.off("Network.requestWillBeSentExtraInfo", onRequestExtraInfo);
306
328
  session.off("Network.responseReceived", onResponse);
307
329
  session.off("Network.responseReceivedExtraInfo", onResponseExtraInfo);
308
330
  session.off("Network.loadingFinished", onFinished);
@@ -711,7 +733,7 @@ function windowHasTransitionBody(params) {
711
733
  const { preIdx, advanceTransitionBodyPattern } = params;
712
734
  if (!advanceTransitionBodyPattern)
713
735
  return false;
714
- const capturesDir = params.capturesDir ?? recon_shared_1.CAPTURES_DIR;
736
+ const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
715
737
  let rx;
716
738
  try {
717
739
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -753,7 +775,7 @@ function windowHasAdvanceTransition(params) {
753
775
  const { preIdx, advanceTransitionBodyPattern } = params;
754
776
  if (!advanceTransitionBodyPattern)
755
777
  return false;
756
- const capturesDir = params.capturesDir ?? recon_shared_1.CAPTURES_DIR;
778
+ const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
757
779
  let rx;
758
780
  try {
759
781
  rx = new RegExp(advanceTransitionBodyPattern);
@@ -902,7 +924,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
902
924
  * keeps healing opportunistically.
903
925
  */
904
926
  function shouldSkipTechnique(params) {
905
- const { technique, priorAttempts, advanceUnmovedAfterAttempt1 } = params;
927
+ const { technique, priorAttempts, advanceUnmovedAfterAttempt1, phantomClickAfterAttempt1 } = params;
906
928
  // Unmoved-advance short-circuit (measured: attempts 2-4 recovered a stuck
907
929
  // advance 0 times in 289 steps). When attempt-1's act-string clicked the Next
908
930
  // and the wizard did NOT move forward (non-advancing POST, or no effect),
@@ -923,6 +945,25 @@ function shouldSkipTechnique(params) {
923
945
  reason: "advance step did not move the wizard on attempt 1; re-observe/re-click cannot advance it — skipping to rephrase/replan",
924
946
  };
925
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.
958
+ if (phantomClickAfterAttempt1 === true &&
959
+ (technique === "observe-act" ||
960
+ technique === "structured-click" ||
961
+ technique === "observe-act-exclude")) {
962
+ return {
963
+ skip: true,
964
+ reason: "attempt 1 was a phantom click (reported success, zero observable effect); re-observe/re-click cannot reach a target the light-DOM resolver can't see — escalating to the deep submit-control locator",
965
+ };
966
+ }
926
967
  if (technique === "structured-click") {
927
968
  const anyXpathResolved = priorAttempts.some((a) => a.triedSelectors.length > 0);
928
969
  if (!anyXpathResolved) {
@@ -1691,8 +1732,9 @@ async function extractInteractiveTargetsNearInvalid(page) {
1691
1732
  /**
1692
1733
  * Scan recent capture files for failed submit-endpoint requests and pull
1693
1734
  * out structured field-level errors from the response body. The cascade
1694
- * already saves every captured request to `CAPTURES_DIR` with its parsed
1695
- * `responseBody`; this helper reads those files back, filters to captures
1735
+ * already saves every captured request to the run's graphql capture dir
1736
+ * (see {@link resolveReconRunDir}) with its parsed `responseBody`; this
1737
+ * helper reads those files back, filters to captures
1696
1738
  * matching the configured submit pattern with status >= 400, and walks
1697
1739
  * common error-shape conventions (`{ errors: [{ field, message }] }`,
1698
1740
  * `{ validation/fieldErrors: { … } }`, `{ message }`).
@@ -1709,7 +1751,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
1709
1751
  * deterministic hostname equality. Empty list / "any-4xx" mode disables
1710
1752
  * the host filter and returns any 4xx in the window.
1711
1753
  */
1712
- ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict") {
1754
+ ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
1713
1755
  if (recentCaptureFilenames.length === 0)
1714
1756
  return "";
1715
1757
  if (mode === "strict" && ownBackendHostnames.length === 0)
@@ -1800,7 +1842,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
1800
1842
  * Returns a numbered evidence list. Empty string when no GA collect
1801
1843
  * captures are present. Advisory — never load-bearing.
1802
1844
  */
1803
- function extractGaEventEvidence(recentCaptureFilenames, capturesDir = recon_shared_1.CAPTURES_DIR) {
1845
+ function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
1804
1846
  if (recentCaptureFilenames.length === 0)
1805
1847
  return "";
1806
1848
  const records = [];
@@ -4265,8 +4307,23 @@ function narrowInvalidFormControl(entry) {
4265
4307
  autoFilled: narrowedAutoFilled,
4266
4308
  };
4267
4309
  }
4310
+ /**
4311
+ * Single source of truth for step-line prefixes. Exists because the cascade
4312
+ * here has only ever received `stepIndex`, while the orchestrator loop in
4313
+ * recon-browser owns the plan array — so half the step lines in a run printed
4314
+ * a `N/total` denominator and half printed a bare `N`.
4315
+ *
4316
+ * Takes a getter rather than a number: a global replan splices new steps into
4317
+ * the live plan array mid-run, so the total must be read when the line is
4318
+ * emitted, not when the step started. Callers with no total omit it and get
4319
+ * the bare form.
4320
+ */
4321
+ function formatStepPrefix(stepIndex, totalSteps) {
4322
+ const total = totalSteps?.();
4323
+ return total === undefined ? `step ${stepIndex + 1}` : `step ${stepIndex + 1}/${total}`;
4324
+ }
4268
4325
  async function probeFormValidityBeforeSubmit(params) {
4269
- const { page, stepIndex, logger } = params;
4326
+ const { page, stepIndex, totalSteps, logger } = params;
4270
4327
  try {
4271
4328
  const raw = await page.evaluate(FORM_VALIDITY_PROBE_EXPR);
4272
4329
  if (!Array.isArray(raw))
@@ -4280,15 +4337,15 @@ async function probeFormValidityBeforeSubmit(params) {
4280
4337
  }
4281
4338
  const autoCount = out.filter((e) => e.autoFilled !== null).length;
4282
4339
  if (out.length > 0) {
4283
- 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}`);
4340
+ 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}`);
4284
4341
  }
4285
4342
  else {
4286
- logger.info(`step ${stepIndex + 1} pre-submit probe: no ng-invalid form controls detected`);
4343
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: no ng-invalid form controls detected`);
4287
4344
  }
4288
4345
  return out;
4289
4346
  }
4290
4347
  catch (err) {
4291
- logger.warn(`step ${stepIndex + 1} pre-submit probe threw: ${(0, errors_1.toErrorMessage)(err)} — proceeding without pre-flight evidence`);
4348
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe threw: ${(0, errors_1.toErrorMessage)(err)} — proceeding without pre-flight evidence`);
4292
4349
  return [];
4293
4350
  }
4294
4351
  }
@@ -4302,7 +4359,7 @@ async function probeFormValidityBeforeSubmit(params) {
4302
4359
  * step is declared "absent". Exported for tests.
4303
4360
  */
4304
4361
  async function probeStepBeforeAttempts(params) {
4305
- const { stagehand, step, stepIndex, logger, captureFn } = params;
4362
+ const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
4306
4363
  try {
4307
4364
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4308
4365
  if (candidates.length === 0) {
@@ -4319,24 +4376,24 @@ async function probeStepBeforeAttempts(params) {
4319
4376
  // where the unfocused observe is also empty — stay "absent".
4320
4377
  const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4321
4378
  if (unfocused.length > 0) {
4322
- logger.info(`step ${stepIndex + 1}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4379
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4323
4380
  return "present";
4324
4381
  }
4325
- logger.info(`step ${stepIndex + 1}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
4382
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
4326
4383
  return "absent";
4327
4384
  }
4328
- logger.info(`step ${stepIndex + 1}: probe found ${candidates.length} candidate(s)`);
4385
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found ${candidates.length} candidate(s)`);
4329
4386
  return "present";
4330
4387
  }
4331
4388
  catch (err) {
4332
4389
  // Bias toward the existing behavior on errors: don't trigger a spurious
4333
4390
  // replan when the probe itself is the broken thing.
4334
- logger.warn(`step ${stepIndex + 1}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
4391
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
4335
4392
  return "present";
4336
4393
  }
4337
4394
  }
4338
4395
  async function executeStepWithHealing(params) {
4339
- 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;
4396
+ 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;
4340
4397
  // Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
4341
4398
  // through executeStepWithHealing's signature so the cascade has it in
4342
4399
  // scope when the invalid-fields judge migration (Task #43) lands. The
@@ -4376,7 +4433,7 @@ async function executeStepWithHealing(params) {
4376
4433
  signalCounter,
4377
4434
  recentCaptureMeta,
4378
4435
  })) {
4379
- logger.info(`step ${stepIndex + 1} resolved by upload primitive`);
4436
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
4380
4437
  trajectory?.push({ stepIndex, verifiedBy: "network" });
4381
4438
  return "completed";
4382
4439
  }
@@ -4387,7 +4444,7 @@ async function executeStepWithHealing(params) {
4387
4444
  // question unanswered. No-op (returns false → falls through) when the step
4388
4445
  // isn't a single-dropdown select or no option matches.
4389
4446
  if (await trySelectPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4390
- logger.info(`step ${stepIndex + 1} resolved by select primitive`);
4447
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
4391
4448
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4392
4449
  return "completed";
4393
4450
  }
@@ -4397,7 +4454,7 @@ async function executeStepWithHealing(params) {
4397
4454
  // trySelectPrimitive (which handles <select> and no-ops on checkbox-only
4398
4455
  // pages). No-op (falls through) when there's no checkbox group or no match.
4399
4456
  if (await tryCheckboxPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4400
- logger.info(`step ${stepIndex + 1} resolved by checkbox primitive`);
4457
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
4401
4458
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4402
4459
  return "completed";
4403
4460
  }
@@ -4408,7 +4465,7 @@ async function executeStepWithHealing(params) {
4408
4465
  // React controlled state (the HCA Basic-Info Step-2 wall). No-op (falls
4409
4466
  // through) when there's no radio group or no confident option match.
4410
4467
  if (await tryRadioPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4411
- logger.info(`step ${stepIndex + 1} resolved by radio primitive`);
4468
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
4412
4469
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4413
4470
  return "completed";
4414
4471
  }
@@ -4419,7 +4476,7 @@ async function executeStepWithHealing(params) {
4419
4476
  // catch-all (parseSelectStep returns null there, so trySelectPrimitive above
4420
4477
  // skipped it) and no-ops when the page has no required-empty select.
4421
4478
  if (await tryFillRequiredSelectsPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4422
- logger.info(`step ${stepIndex + 1} resolved by required-select primitive`);
4479
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
4423
4480
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4424
4481
  return "completed";
4425
4482
  }
@@ -4435,6 +4492,7 @@ async function executeStepWithHealing(params) {
4435
4492
  stagehand,
4436
4493
  step,
4437
4494
  stepIndex,
4495
+ totalSteps,
4438
4496
  logger,
4439
4497
  captureFn,
4440
4498
  });
@@ -4445,10 +4503,10 @@ async function executeStepWithHealing(params) {
4445
4503
  // can't resolve the widget) — skipping would leave a required field empty
4446
4504
  // and silently doom the later submit. Fall through to the cascade instead.
4447
4505
  if (await hasUnfilledRequiredControlForStep(page, step)) {
4448
- logger.info(`step ${stepIndex + 1} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
4506
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
4449
4507
  }
4450
4508
  else {
4451
- logger.info(`step ${stepIndex + 1} skipped (optional, probe found no candidates)`);
4509
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, probe found no candidates)`);
4452
4510
  return "skipped";
4453
4511
  }
4454
4512
  }
@@ -4463,7 +4521,7 @@ async function executeStepWithHealing(params) {
4463
4521
  preMetaLength: stepStartMetaLength,
4464
4522
  });
4465
4523
  if (transitionUrl !== null) {
4466
- logger.info(`step ${stepIndex + 1} skipped (probe absent but recent transition detected: ${transitionUrl})`);
4524
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (probe absent but recent transition detected: ${transitionUrl})`);
4467
4525
  trajectory?.push({ stepIndex, verifiedBy: "url" });
4468
4526
  return "completed";
4469
4527
  }
@@ -4478,8 +4536,8 @@ async function executeStepWithHealing(params) {
4478
4536
  ownBackendHostnames,
4479
4537
  });
4480
4538
  if (backendErrorUrl !== null) {
4481
- logger.error(`step ${stepIndex + 1} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
4482
- throw new errors_2.StepVerificationError(`step ${stepIndex + 1} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
4539
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
4540
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
4483
4541
  }
4484
4542
  // Capture diagnostics + write a failure dump BEFORE throwing so the
4485
4543
  // global replan path's `readFailureDumpEvidence` can populate the
@@ -4507,7 +4565,7 @@ async function executeStepWithHealing(params) {
4507
4565
  bodyOuterHtml,
4508
4566
  unfocusedObserve,
4509
4567
  }) ?? null;
4510
- throw new errors_2.StepVerificationError(`step ${stepIndex + 1} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
4568
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
4511
4569
  }
4512
4570
  // Pre-submit form-validity probe. Fires on the canonical submit step
4513
4571
  // (either the final flow step OR a step explicitly flagged `submitStep:
@@ -4532,6 +4590,7 @@ async function executeStepWithHealing(params) {
4532
4590
  const invalidControls = await probeFormValidityBeforeSubmit({
4533
4591
  page,
4534
4592
  stepIndex,
4593
+ totalSteps,
4535
4594
  logger,
4536
4595
  });
4537
4596
  for (const c of invalidControls) {
@@ -4573,6 +4632,7 @@ async function executeStepWithHealing(params) {
4573
4632
  resolvedMethod: null,
4574
4633
  resolvedArguments: null,
4575
4634
  verifiedBy: null,
4635
+ phantomClickVerdict: null,
4576
4636
  });
4577
4637
  }
4578
4638
  // Brief settle window after auto-picks so Angular's change-detection
@@ -4588,6 +4648,11 @@ async function executeStepWithHealing(params) {
4588
4648
  // NOT move the wizard forward. Read at the top of attempts 2-4 to skip the
4589
4649
  // proven-dead re-observe/re-click techniques (see shouldSkipTechnique).
4590
4650
  let advanceUnmovedAfterAttempt1 = false;
4651
+ // Set after attempt 1: Stagehand reported success but pre/post shows zero
4652
+ // observable effect (see classifyPhantomClick). Reroutes attempt 2 to
4653
+ // deep-submit-locator instead of observe-act, since re-resolving via the
4654
+ // same light-DOM view cannot reach a target the resolver can't see.
4655
+ let phantomClickAfterAttempt1 = false;
4591
4656
  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
4592
4657
  // Telemetry-driven technique-skip: when a cascade technique's
4593
4658
  // preconditions cannot be met by the prior attempts' state, running
@@ -4596,7 +4661,9 @@ async function executeStepWithHealing(params) {
4596
4661
  // techniques faster.
4597
4662
  if (attempt > 1) {
4598
4663
  const wouldBeTechnique = attempt === 2
4599
- ? "observe-act"
4664
+ ? phantomClickAfterAttempt1
4665
+ ? "deep-submit-locator"
4666
+ : "observe-act"
4600
4667
  : attempt === 3
4601
4668
  ? "structured-click"
4602
4669
  : attempt === 4
@@ -4610,9 +4677,10 @@ async function executeStepWithHealing(params) {
4610
4677
  errorMessage: a.errorMessage,
4611
4678
  })),
4612
4679
  advanceUnmovedAfterAttempt1,
4680
+ phantomClickAfterAttempt1,
4613
4681
  });
4614
4682
  if (decision.skip) {
4615
- logger.info(`step ${stepIndex + 1} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
4683
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
4616
4684
  failureReasons.push(`attempt ${attempt} skipped: ${decision.reason}`);
4617
4685
  continue;
4618
4686
  }
@@ -4643,6 +4711,7 @@ async function executeStepWithHealing(params) {
4643
4711
  resolvedMethod: null,
4644
4712
  resolvedArguments: null,
4645
4713
  verifiedBy: null,
4714
+ phantomClickVerdict: null,
4646
4715
  };
4647
4716
  // First resolved action from Stagehand's `act` result — used to decide
4648
4717
  // whether this attempt's signal should come from the network/URL pair or
@@ -4679,6 +4748,124 @@ async function executeStepWithHealing(params) {
4679
4748
  }
4680
4749
  }
4681
4750
  }
4751
+ else if (attempt === 2 && phantomClickAfterAttempt1) {
4752
+ // Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
4753
+ // reported success but pre/post showed zero effect), so the target is
4754
+ // almost certainly unreachable via document.querySelectorAll — most
4755
+ // likely rendered inside an open shadow root by a web-component /
4756
+ // framework-native submit control (see recon-submit-phantom-click bug
4757
+ // report). Rank every submit-shaped candidate the deep traversal can
4758
+ // reach and click the top-ranked one; the ranking already excludes
4759
+ // Back/Cancel/Save-draft-shaped controls, so a false-positive submit
4760
+ // click cannot fire.
4761
+ //
4762
+ // Rank and click are two separate page.evaluate round trips over a
4763
+ // live Angular page, so any re-render between them shifts every
4764
+ // deepIndex — the click then lands on nothing (`{clicked: false}`),
4765
+ // not because the page is broken but because the snapshot the index
4766
+ // was computed from is already gone. That's a transient, self-clearing
4767
+ // condition, so re-rank against the CURRENT DOM and click once more
4768
+ // before giving up. Capped at one retry (two rank+click rounds total)
4769
+ // so a page re-rendering on every tick can't turn this into a loop.
4770
+ record.technique = "deep-submit-locator";
4771
+ for (let deepAttempt = 1; deepAttempt <= 2; deepAttempt++) {
4772
+ let ranked;
4773
+ try {
4774
+ ranked = (await page.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
4775
+ }
4776
+ catch (err) {
4777
+ // A thrown evaluate (page navigated away / frame detached) is not
4778
+ // a stale-index race — re-ranking a detached page will throw
4779
+ // again, so don't retry; record it and let the cascade move on.
4780
+ record.errorMessage = `deep-submit-locator: rank evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
4781
+ break;
4782
+ }
4783
+ if (ranked.length === 0) {
4784
+ record.errorMessage = "deep-submit-locator: no submit-shaped candidate found";
4785
+ break;
4786
+ }
4787
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
4788
+ const top = ranked[0];
4789
+ record.instruction = `deep-submit-locator: ${top.tag} "${top.accessibleName}" (tier ${top.tier})`;
4790
+ record.triedSelectors = [`deep-index:${top.deepIndex}`];
4791
+ triedSelectors.push(`deep-index:${top.deepIndex}`);
4792
+ let clickResult;
4793
+ try {
4794
+ clickResult = (await page.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
4795
+ }
4796
+ catch (err) {
4797
+ record.errorMessage = `deep-submit-locator: click evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
4798
+ break;
4799
+ }
4800
+ record.actResultSuccess = clickResult.clicked;
4801
+ record.actResultDescription = clickResult.clicked
4802
+ ? `deep-submit-locator clicked ${top.tag} "${top.accessibleName}"`
4803
+ : "deep-submit-locator: candidate vanished before click (deepIndex stale)";
4804
+ if (clickResult.clicked) {
4805
+ // Synthesize a click action so downstream verification (network /
4806
+ // url / dom) treats this exactly like any other resolved click.
4807
+ resolvedAction = {
4808
+ selector: `deep-index:${top.deepIndex}`,
4809
+ description: record.actResultDescription,
4810
+ method: "click",
4811
+ };
4812
+ // Runner-up retry: the top pick can itself phantom-click (a
4813
+ // second web-component / shadow-root control that LOOKS
4814
+ // submit-shaped but doesn't wire a real handler — see
4815
+ // submit-control.ts's module docblock). Re-snapshot right here
4816
+ // and re-classify with the same classifyPhantomClick primitive
4817
+ // that escalated attempt 1, so a phantom top pick doesn't fall
4818
+ // through to attempt 3's structured-click, which can't reach a
4819
+ // shadow-root control either. Cap at ranked[1] only (not the
4820
+ // full list) so a page with many submit-shaped candidates can't
4821
+ // burn the step budget probing all of them.
4822
+ const runnerUp = ranked[1];
4823
+ if (runnerUp) {
4824
+ const midPost = await snapshotPage(page, signalCounter);
4825
+ const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
4826
+ actResultSuccess: true,
4827
+ pre,
4828
+ post: midPost,
4829
+ });
4830
+ if (topVerdict === "phantom") {
4831
+ 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})`);
4832
+ record.instruction = `deep-submit-locator: ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier}), runner-up after top pick ${top.tag} "${top.accessibleName}" phantomed`;
4833
+ record.triedSelectors = [
4834
+ `deep-index:${top.deepIndex}`,
4835
+ `deep-index:${runnerUp.deepIndex}`,
4836
+ ];
4837
+ triedSelectors.push(`deep-index:${runnerUp.deepIndex}`);
4838
+ const runnerUpClickResult = (await page
4839
+ .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
4840
+ .catch(() => ({ clicked: false })));
4841
+ record.actResultSuccess = runnerUpClickResult.clicked;
4842
+ record.actResultDescription = runnerUpClickResult.clicked
4843
+ ? `deep-submit-locator clicked runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" after top pick phantomed`
4844
+ : "deep-submit-locator: runner-up candidate vanished before click (deepIndex stale)";
4845
+ resolvedAction = runnerUpClickResult.clicked
4846
+ ? {
4847
+ selector: `deep-index:${runnerUp.deepIndex}`,
4848
+ description: record.actResultDescription,
4849
+ method: "click",
4850
+ }
4851
+ : null;
4852
+ if (!runnerUpClickResult.clicked) {
4853
+ record.errorMessage = record.actResultDescription;
4854
+ }
4855
+ }
4856
+ }
4857
+ // The top pick (or its runner-up) was reached and clicked. A
4858
+ // runner-up that itself vanished is not the stale-index race the
4859
+ // re-rank exists for — the rank was fresh enough to click the top
4860
+ // pick — so exit either way and let the cascade classify.
4861
+ break;
4862
+ }
4863
+ record.errorMessage = record.actResultDescription;
4864
+ if (deepAttempt === 1) {
4865
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator: deepIndex stale on first click, re-ranking once`);
4866
+ }
4867
+ }
4868
+ }
4682
4869
  else if (attempt === 2 || attempt === 4) {
4683
4870
  record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
4684
4871
  const observeOptions = attempt === 4 && triedSelectors.length > 0
@@ -4706,12 +4893,12 @@ async function executeStepWithHealing(params) {
4706
4893
  // don't fast-skip — let the healing cascade continue so the
4707
4894
  // required field gets answered instead of silently doomed.
4708
4895
  if (await hasUnfilledRequiredControlForStep(page, step)) {
4709
- logger.info(`step ${stepIndex + 1} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
4896
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
4710
4897
  }
4711
4898
  else {
4712
4899
  record.verifiedBy = null;
4713
4900
  attempts.push(record);
4714
- logger.info(`step ${stepIndex + 1} skipped (optional, no candidates after act+observe)`);
4901
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, no candidates after act+observe)`);
4715
4902
  return "skipped";
4716
4903
  }
4717
4904
  }
@@ -4729,7 +4916,7 @@ async function executeStepWithHealing(params) {
4729
4916
  record.triedSelectors = [target.selector];
4730
4917
  attempts.push(record);
4731
4918
  failureReasons.push(record.errorMessage);
4732
- logger.info(`step ${stepIndex + 1} attempt ${attempt}: ${record.errorMessage}`);
4919
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
4733
4920
  continue;
4734
4921
  }
4735
4922
  record.instruction = target.description;
@@ -4789,7 +4976,7 @@ async function executeStepWithHealing(params) {
4789
4976
  record.actResultSuccess = false;
4790
4977
  }
4791
4978
  else if (readback.outcome === "differs") {
4792
- logger.info(`step ${stepIndex + 1} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
4979
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
4793
4980
  }
4794
4981
  }
4795
4982
  }
@@ -5061,7 +5248,7 @@ async function executeStepWithHealing(params) {
5061
5248
  intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
5062
5249
  });
5063
5250
  if (advanceGateActive && !networkIsRealAdvance) {
5064
- 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`);
5251
+ 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`);
5065
5252
  }
5066
5253
  // DOM-only-advance veto (opt-in). A rephrase can turn an advance/"Next" step
5067
5254
  // into a field click (e.g. "click the Yes radio for '18?' then Next"), which
@@ -5087,7 +5274,7 @@ async function executeStepWithHealing(params) {
5087
5274
  ? domVerified
5088
5275
  : false;
5089
5276
  if (domVerified && !domVerifiedForStep) {
5090
- 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`);
5277
+ 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`);
5091
5278
  }
5092
5279
  let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep;
5093
5280
  // Final-step submit-verification gate. Replaces the deterministic
@@ -5189,7 +5376,7 @@ async function executeStepWithHealing(params) {
5189
5376
  // Any-4xx fallback. The judge said failed; surface any captured 4xx
5190
5377
  // body's field-level error JSON for the rephrase prompt downstream
5191
5378
  // — same role as before the migration.
5192
- const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], recon_shared_1.CAPTURES_DIR, "any-4xx");
5379
+ const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], (0, recon_shared_1.resolveReconRunDir)().graphqlDir, "any-4xx");
5193
5380
  if (fallbackEvidence.length > 0) {
5194
5381
  failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
5195
5382
  }
@@ -5329,7 +5516,7 @@ async function executeStepWithHealing(params) {
5329
5516
  retryNetworkIsRealAdvance,
5330
5517
  });
5331
5518
  if (fallbackDomOnlyAdvance) {
5332
- logger.info(`step ${stepIndex + 1} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
5519
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
5333
5520
  }
5334
5521
  let retryVerified = !clickBlockedByInvalid &&
5335
5522
  !fallbackDomOnlyAdvance &&
@@ -5407,7 +5594,7 @@ async function executeStepWithHealing(params) {
5407
5594
  failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
5408
5595
  }
5409
5596
  }
5410
- 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}`);
5597
+ 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}`);
5411
5598
  if (retryVerified) {
5412
5599
  if (record.verifiedBy === null) {
5413
5600
  record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
@@ -5415,24 +5602,24 @@ async function executeStepWithHealing(params) {
5415
5602
  record.post = retryPost;
5416
5603
  attempts.push(record);
5417
5604
  if (attempt > 1) {
5418
- logger.info(`step ${stepIndex + 1} healed on attempt ${attempt} via ${record.technique} + el.click() fallback`);
5605
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} + el.click() fallback`);
5419
5606
  }
5420
5607
  else {
5421
- logger.info(`step ${stepIndex + 1} succeeded on attempt 1 via ${record.technique} + el.click() fallback`);
5608
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} + el.click() fallback`);
5422
5609
  }
5423
5610
  trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
5424
5611
  return "completed";
5425
5612
  }
5426
5613
  }
5427
5614
  catch (probeErr) {
5428
- logger.warn(`n+16 probe: step=${stepIndex + 1} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
5615
+ logger.warn(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
5429
5616
  }
5430
5617
  }
5431
5618
  }
5432
5619
  attempts.push(record);
5433
5620
  if (verified) {
5434
5621
  if (attempt > 1) {
5435
- logger.info(`step ${stepIndex + 1} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5622
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5436
5623
  }
5437
5624
  else {
5438
5625
  // Why log first-try wins explicitly: prior to this change, attempt-1
@@ -5442,19 +5629,29 @@ async function executeStepWithHealing(params) {
5442
5629
  // collapse" (log showed 2 heals) but telemetry calls.ndjson showed
5443
5630
  // 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
5444
5631
  // log match telemetry and prevents the same false alarm.
5445
- logger.info(`step ${stepIndex + 1} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5632
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5446
5633
  }
5447
5634
  trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
5448
5635
  return "completed";
5449
5636
  }
5450
5637
  const effectSignals = describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLength);
5638
+ // Phantom-click verdict, computed from the SAME pre/post pair
5639
+ // describeAttemptEffectSignals just rendered — not recomputed deltas.
5640
+ // Recorded on every unverified attempt (not just attempt 1) so the
5641
+ // failure dump's attempts[] always carries the classification; only
5642
+ // attempt 1's verdict drives the escalation flag below.
5643
+ record.phantomClickVerdict = (0, phantom_click_1.classifyPhantomClick)({
5644
+ actResultSuccess: record.actResultSuccess,
5645
+ pre,
5646
+ post,
5647
+ });
5451
5648
  const reason = record.errorMessage
5452
5649
  ? effectSignals
5453
5650
  ? `${record.errorMessage}; ${effectSignals}`
5454
5651
  : record.errorMessage
5455
5652
  : effectSignals || "no observable effect (no network, url, or dom change)";
5456
5653
  failureReasons.push(reason);
5457
- logger.warn(`step ${stepIndex + 1} attempt ${attempt} (${record.technique}) produced no observable effect — ${reason}`);
5654
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${record.technique}) produced no observable effect — ${reason}`);
5458
5655
  // One additive strategy among many: when a click on a final-step
5459
5656
  // submit/continue button fails AND the page surfaces a visible
5460
5657
  // <error> sibling next to a touched+dirty ng-invalid wrapper, surface
@@ -5479,7 +5676,7 @@ async function executeStepWithHealing(params) {
5479
5676
  for (const p of pairs) {
5480
5677
  const validationReason = formatValidationRejectedReason(p);
5481
5678
  failureReasons.push(validationReason);
5482
- logger.warn(`step ${stepIndex + 1} ${validationReason}`);
5679
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${validationReason}`);
5483
5680
  }
5484
5681
  }
5485
5682
  // Telemetry-driven early-exit: when attempt 1 on a final-Submit click
@@ -5501,6 +5698,17 @@ async function executeStepWithHealing(params) {
5501
5698
  isAdvanceStep(step) &&
5502
5699
  !urlChanged &&
5503
5700
  !networkIsRealAdvance;
5701
+ // Attempt 1 phantom-clicked: Stagehand reported success but pre/post
5702
+ // shows zero observable effect. The zero-effect delta is the primary
5703
+ // signal (classifyPhantomClick above); the live AISDK elementId
5704
+ // suppression counter is corroborating evidence only — logged, never
5705
+ // gating, since a nonzero count alone is too weak a signal on a run
5706
+ // that sees dozens of suppressions across hundreds of unrelated steps.
5707
+ phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
5708
+ if (phantomClickAfterAttempt1) {
5709
+ const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
5710
+ 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)` : ""} — escalating attempt 2 to deep-submit-locator`);
5711
+ }
5504
5712
  const postAttemptInvalidCount = await countNgInvalidContainers(page);
5505
5713
  const earlyExit = isSubmitRevealedInvalid({
5506
5714
  // Treat the canonical submit click as "final" for this predicate
@@ -5516,7 +5724,7 @@ async function executeStepWithHealing(params) {
5516
5724
  if (earlyExit) {
5517
5725
  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`;
5518
5726
  failureReasons.push(exitReason);
5519
- logger.warn(`step ${stepIndex + 1} ${exitReason}`);
5727
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
5520
5728
  break;
5521
5729
  }
5522
5730
  // Telemetry-driven early-exit for interior ADVANCE steps: if the Next
@@ -5536,7 +5744,7 @@ async function executeStepWithHealing(params) {
5536
5744
  if (advanceStalled) {
5537
5745
  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`;
5538
5746
  failureReasons.push(exitReason);
5539
- logger.warn(`step ${stepIndex + 1} ${exitReason}`);
5747
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
5540
5748
  break;
5541
5749
  }
5542
5750
  }
@@ -5564,9 +5772,9 @@ async function executeStepWithHealing(params) {
5564
5772
  unfocusedObserve,
5565
5773
  }) ?? null;
5566
5774
  if (dumpPath !== null) {
5567
- logger.error(`step ${stepIndex + 1} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
5775
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
5568
5776
  }
5569
- throw new errors_2.StepVerificationError(`step ${stepIndex + 1} (${step.slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, "cascade-exhausted");
5777
+ 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");
5570
5778
  }
5571
5779
  /** SPA-readiness gate defaults — mirror the recon CLI's post-navigation wait. */
5572
5780
  const SPA_READINESS_TIMEOUT_MS = 15_000;
@@ -5648,6 +5856,7 @@ async function runHealingFlow(deps) {
5648
5856
  upload: s.upload,
5649
5857
  submitStep: s.submitStep,
5650
5858
  stepIndex: i,
5859
+ totalSteps: () => steps.length,
5651
5860
  phase: "flow",
5652
5861
  signalCounter,
5653
5862
  recentCaptures,