@enricai/barnacle 1.6.1 → 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 (53) hide show
  1. package/README.md +13 -11
  2. package/dist/scraper/deep-query.d.ts +36 -0
  3. package/dist/scraper/deep-query.d.ts.map +1 -0
  4. package/dist/scraper/deep-query.js +87 -0
  5. package/dist/scraper/deep-query.js.map +1 -0
  6. package/dist/scraper/errors.d.ts +11 -2
  7. package/dist/scraper/errors.d.ts.map +1 -1
  8. package/dist/scraper/errors.js +10 -1
  9. package/dist/scraper/errors.js.map +1 -1
  10. package/dist/scraper/flow-runner.d.ts +55 -4
  11. package/dist/scraper/flow-runner.d.ts.map +1 -1
  12. package/dist/scraper/flow-runner.js +240 -50
  13. package/dist/scraper/flow-runner.js.map +1 -1
  14. package/dist/scraper/phantom-click.d.ts +45 -0
  15. package/dist/scraper/phantom-click.d.ts.map +1 -0
  16. package/dist/scraper/phantom-click.js +37 -0
  17. package/dist/scraper/phantom-click.js.map +1 -0
  18. package/dist/scraper/session-browserbase.d.ts +48 -0
  19. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  20. package/dist/scraper/session-browserbase.js +10 -5
  21. package/dist/scraper/session-browserbase.js.map +1 -1
  22. package/dist/scraper/session-shared.d.ts +6 -0
  23. package/dist/scraper/session-shared.d.ts.map +1 -1
  24. package/dist/scraper/session-shared.js.map +1 -1
  25. package/dist/scraper/submit-control.d.ts +51 -0
  26. package/dist/scraper/submit-control.d.ts.map +1 -0
  27. package/dist/scraper/submit-control.js +142 -0
  28. package/dist/scraper/submit-control.js.map +1 -0
  29. package/dist/scripts/recon-browser.d.ts +23 -18
  30. package/dist/scripts/recon-browser.d.ts.map +1 -1
  31. package/dist/scripts/recon-browser.js +51 -42
  32. package/dist/scripts/recon-browser.js.map +1 -1
  33. package/dist/scripts/recon-generate.d.ts +28 -7
  34. package/dist/scripts/recon-generate.d.ts.map +1 -1
  35. package/dist/scripts/recon-generate.js +50 -24
  36. package/dist/scripts/recon-generate.js.map +1 -1
  37. package/dist/scripts/recon-http.d.ts +36 -5
  38. package/dist/scripts/recon-http.d.ts.map +1 -1
  39. package/dist/scripts/recon-http.js +77 -47
  40. package/dist/scripts/recon-http.js.map +1 -1
  41. package/dist/scripts/recon-replay-jobs.d.ts +25 -1
  42. package/dist/scripts/recon-replay-jobs.d.ts.map +1 -1
  43. package/dist/scripts/recon-replay-jobs.js +33 -23
  44. package/dist/scripts/recon-replay-jobs.js.map +1 -1
  45. package/dist/scripts/recon-shared.d.ts +35 -0
  46. package/dist/scripts/recon-shared.d.ts.map +1 -1
  47. package/dist/scripts/recon-shared.js +92 -0
  48. package/dist/scripts/recon-shared.js.map +1 -1
  49. package/dist/scripts/recon-summarize.d.ts +5 -1
  50. package/dist/scripts/recon-summarize.d.ts.map +1 -1
  51. package/dist/scripts/recon-summarize.js +19 -7
  52. package/dist/scripts/recon-summarize.js.map +1 -1
  53. 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. */
@@ -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);
@@ -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);
@@ -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 } = 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,25 @@ 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.
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
+ }
945
967
  if (technique === "structured-click") {
946
968
  const anyXpathResolved = priorAttempts.some((a) => a.triedSelectors.length > 0);
947
969
  if (!anyXpathResolved) {
@@ -1710,8 +1732,9 @@ async function extractInteractiveTargetsNearInvalid(page) {
1710
1732
  /**
1711
1733
  * Scan recent capture files for failed submit-endpoint requests and pull
1712
1734
  * 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
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
1715
1738
  * matching the configured submit pattern with status >= 400, and walks
1716
1739
  * common error-shape conventions (`{ errors: [{ field, message }] }`,
1717
1740
  * `{ validation/fieldErrors: { … } }`, `{ message }`).
@@ -1728,7 +1751,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
1728
1751
  * deterministic hostname equality. Empty list / "any-4xx" mode disables
1729
1752
  * the host filter and returns any 4xx in the window.
1730
1753
  */
1731
- ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict") {
1754
+ ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
1732
1755
  if (recentCaptureFilenames.length === 0)
1733
1756
  return "";
1734
1757
  if (mode === "strict" && ownBackendHostnames.length === 0)
@@ -1819,7 +1842,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
1819
1842
  * Returns a numbered evidence list. Empty string when no GA collect
1820
1843
  * captures are present. Advisory — never load-bearing.
1821
1844
  */
1822
- function extractGaEventEvidence(recentCaptureFilenames, capturesDir = recon_shared_1.CAPTURES_DIR) {
1845
+ function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
1823
1846
  if (recentCaptureFilenames.length === 0)
1824
1847
  return "";
1825
1848
  const records = [];
@@ -4284,8 +4307,23 @@ function narrowInvalidFormControl(entry) {
4284
4307
  autoFilled: narrowedAutoFilled,
4285
4308
  };
4286
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
+ }
4287
4325
  async function probeFormValidityBeforeSubmit(params) {
4288
- const { page, stepIndex, logger } = params;
4326
+ const { page, stepIndex, totalSteps, logger } = params;
4289
4327
  try {
4290
4328
  const raw = await page.evaluate(FORM_VALIDITY_PROBE_EXPR);
4291
4329
  if (!Array.isArray(raw))
@@ -4299,15 +4337,15 @@ async function probeFormValidityBeforeSubmit(params) {
4299
4337
  }
4300
4338
  const autoCount = out.filter((e) => e.autoFilled !== null).length;
4301
4339
  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}`);
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}`);
4303
4341
  }
4304
4342
  else {
4305
- 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`);
4306
4344
  }
4307
4345
  return out;
4308
4346
  }
4309
4347
  catch (err) {
4310
- 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`);
4311
4349
  return [];
4312
4350
  }
4313
4351
  }
@@ -4321,7 +4359,7 @@ async function probeFormValidityBeforeSubmit(params) {
4321
4359
  * step is declared "absent". Exported for tests.
4322
4360
  */
4323
4361
  async function probeStepBeforeAttempts(params) {
4324
- const { stagehand, step, stepIndex, logger, captureFn } = params;
4362
+ const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
4325
4363
  try {
4326
4364
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4327
4365
  if (candidates.length === 0) {
@@ -4338,24 +4376,24 @@ async function probeStepBeforeAttempts(params) {
4338
4376
  // where the unfocused observe is also empty — stay "absent".
4339
4377
  const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4340
4378
  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)`);
4379
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4342
4380
  return "present";
4343
4381
  }
4344
- 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)`);
4345
4383
  return "absent";
4346
4384
  }
4347
- logger.info(`step ${stepIndex + 1}: probe found ${candidates.length} candidate(s)`);
4385
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found ${candidates.length} candidate(s)`);
4348
4386
  return "present";
4349
4387
  }
4350
4388
  catch (err) {
4351
4389
  // Bias toward the existing behavior on errors: don't trigger a spurious
4352
4390
  // 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)`);
4391
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
4354
4392
  return "present";
4355
4393
  }
4356
4394
  }
4357
4395
  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;
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;
4359
4397
  // Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
4360
4398
  // through executeStepWithHealing's signature so the cascade has it in
4361
4399
  // scope when the invalid-fields judge migration (Task #43) lands. The
@@ -4395,7 +4433,7 @@ async function executeStepWithHealing(params) {
4395
4433
  signalCounter,
4396
4434
  recentCaptureMeta,
4397
4435
  })) {
4398
- logger.info(`step ${stepIndex + 1} resolved by upload primitive`);
4436
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
4399
4437
  trajectory?.push({ stepIndex, verifiedBy: "network" });
4400
4438
  return "completed";
4401
4439
  }
@@ -4406,7 +4444,7 @@ async function executeStepWithHealing(params) {
4406
4444
  // question unanswered. No-op (returns false → falls through) when the step
4407
4445
  // isn't a single-dropdown select or no option matches.
4408
4446
  if (await trySelectPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4409
- logger.info(`step ${stepIndex + 1} resolved by select primitive`);
4447
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
4410
4448
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4411
4449
  return "completed";
4412
4450
  }
@@ -4416,7 +4454,7 @@ async function executeStepWithHealing(params) {
4416
4454
  // trySelectPrimitive (which handles <select> and no-ops on checkbox-only
4417
4455
  // pages). No-op (falls through) when there's no checkbox group or no match.
4418
4456
  if (await tryCheckboxPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4419
- logger.info(`step ${stepIndex + 1} resolved by checkbox primitive`);
4457
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
4420
4458
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4421
4459
  return "completed";
4422
4460
  }
@@ -4427,7 +4465,7 @@ async function executeStepWithHealing(params) {
4427
4465
  // React controlled state (the HCA Basic-Info Step-2 wall). No-op (falls
4428
4466
  // through) when there's no radio group or no confident option match.
4429
4467
  if (await tryRadioPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4430
- logger.info(`step ${stepIndex + 1} resolved by radio primitive`);
4468
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
4431
4469
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4432
4470
  return "completed";
4433
4471
  }
@@ -4438,7 +4476,7 @@ async function executeStepWithHealing(params) {
4438
4476
  // catch-all (parseSelectStep returns null there, so trySelectPrimitive above
4439
4477
  // skipped it) and no-ops when the page has no required-empty select.
4440
4478
  if (await tryFillRequiredSelectsPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4441
- logger.info(`step ${stepIndex + 1} resolved by required-select primitive`);
4479
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
4442
4480
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4443
4481
  return "completed";
4444
4482
  }
@@ -4454,6 +4492,7 @@ async function executeStepWithHealing(params) {
4454
4492
  stagehand,
4455
4493
  step,
4456
4494
  stepIndex,
4495
+ totalSteps,
4457
4496
  logger,
4458
4497
  captureFn,
4459
4498
  });
@@ -4464,10 +4503,10 @@ async function executeStepWithHealing(params) {
4464
4503
  // can't resolve the widget) — skipping would leave a required field empty
4465
4504
  // and silently doom the later submit. Fall through to the cascade instead.
4466
4505
  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)`);
4506
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
4468
4507
  }
4469
4508
  else {
4470
- logger.info(`step ${stepIndex + 1} skipped (optional, probe found no candidates)`);
4509
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, probe found no candidates)`);
4471
4510
  return "skipped";
4472
4511
  }
4473
4512
  }
@@ -4482,7 +4521,7 @@ async function executeStepWithHealing(params) {
4482
4521
  preMetaLength: stepStartMetaLength,
4483
4522
  });
4484
4523
  if (transitionUrl !== null) {
4485
- 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})`);
4486
4525
  trajectory?.push({ stepIndex, verifiedBy: "url" });
4487
4526
  return "completed";
4488
4527
  }
@@ -4497,8 +4536,8 @@ async function executeStepWithHealing(params) {
4497
4536
  ownBackendHostnames,
4498
4537
  });
4499
4538
  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");
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");
4502
4541
  }
4503
4542
  // Capture diagnostics + write a failure dump BEFORE throwing so the
4504
4543
  // global replan path's `readFailureDumpEvidence` can populate the
@@ -4526,7 +4565,7 @@ async function executeStepWithHealing(params) {
4526
4565
  bodyOuterHtml,
4527
4566
  unfocusedObserve,
4528
4567
  }) ?? 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");
4568
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
4530
4569
  }
4531
4570
  // Pre-submit form-validity probe. Fires on the canonical submit step
4532
4571
  // (either the final flow step OR a step explicitly flagged `submitStep:
@@ -4551,6 +4590,7 @@ async function executeStepWithHealing(params) {
4551
4590
  const invalidControls = await probeFormValidityBeforeSubmit({
4552
4591
  page,
4553
4592
  stepIndex,
4593
+ totalSteps,
4554
4594
  logger,
4555
4595
  });
4556
4596
  for (const c of invalidControls) {
@@ -4592,6 +4632,7 @@ async function executeStepWithHealing(params) {
4592
4632
  resolvedMethod: null,
4593
4633
  resolvedArguments: null,
4594
4634
  verifiedBy: null,
4635
+ phantomClickVerdict: null,
4595
4636
  });
4596
4637
  }
4597
4638
  // Brief settle window after auto-picks so Angular's change-detection
@@ -4607,6 +4648,11 @@ async function executeStepWithHealing(params) {
4607
4648
  // NOT move the wizard forward. Read at the top of attempts 2-4 to skip the
4608
4649
  // proven-dead re-observe/re-click techniques (see shouldSkipTechnique).
4609
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;
4610
4656
  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
4611
4657
  // Telemetry-driven technique-skip: when a cascade technique's
4612
4658
  // preconditions cannot be met by the prior attempts' state, running
@@ -4615,7 +4661,9 @@ async function executeStepWithHealing(params) {
4615
4661
  // techniques faster.
4616
4662
  if (attempt > 1) {
4617
4663
  const wouldBeTechnique = attempt === 2
4618
- ? "observe-act"
4664
+ ? phantomClickAfterAttempt1
4665
+ ? "deep-submit-locator"
4666
+ : "observe-act"
4619
4667
  : attempt === 3
4620
4668
  ? "structured-click"
4621
4669
  : attempt === 4
@@ -4629,9 +4677,10 @@ async function executeStepWithHealing(params) {
4629
4677
  errorMessage: a.errorMessage,
4630
4678
  })),
4631
4679
  advanceUnmovedAfterAttempt1,
4680
+ phantomClickAfterAttempt1,
4632
4681
  });
4633
4682
  if (decision.skip) {
4634
- logger.info(`step ${stepIndex + 1} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
4683
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
4635
4684
  failureReasons.push(`attempt ${attempt} skipped: ${decision.reason}`);
4636
4685
  continue;
4637
4686
  }
@@ -4662,6 +4711,7 @@ async function executeStepWithHealing(params) {
4662
4711
  resolvedMethod: null,
4663
4712
  resolvedArguments: null,
4664
4713
  verifiedBy: null,
4714
+ phantomClickVerdict: null,
4665
4715
  };
4666
4716
  // First resolved action from Stagehand's `act` result — used to decide
4667
4717
  // whether this attempt's signal should come from the network/URL pair or
@@ -4698,6 +4748,124 @@ async function executeStepWithHealing(params) {
4698
4748
  }
4699
4749
  }
4700
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
+ }
4701
4869
  else if (attempt === 2 || attempt === 4) {
4702
4870
  record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
4703
4871
  const observeOptions = attempt === 4 && triedSelectors.length > 0
@@ -4725,12 +4893,12 @@ async function executeStepWithHealing(params) {
4725
4893
  // don't fast-skip — let the healing cascade continue so the
4726
4894
  // required field gets answered instead of silently doomed.
4727
4895
  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)`);
4896
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
4729
4897
  }
4730
4898
  else {
4731
4899
  record.verifiedBy = null;
4732
4900
  attempts.push(record);
4733
- 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)`);
4734
4902
  return "skipped";
4735
4903
  }
4736
4904
  }
@@ -4748,7 +4916,7 @@ async function executeStepWithHealing(params) {
4748
4916
  record.triedSelectors = [target.selector];
4749
4917
  attempts.push(record);
4750
4918
  failureReasons.push(record.errorMessage);
4751
- logger.info(`step ${stepIndex + 1} attempt ${attempt}: ${record.errorMessage}`);
4919
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
4752
4920
  continue;
4753
4921
  }
4754
4922
  record.instruction = target.description;
@@ -4808,7 +4976,7 @@ async function executeStepWithHealing(params) {
4808
4976
  record.actResultSuccess = false;
4809
4977
  }
4810
4978
  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)`);
4979
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
4812
4980
  }
4813
4981
  }
4814
4982
  }
@@ -5080,7 +5248,7 @@ async function executeStepWithHealing(params) {
5080
5248
  intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
5081
5249
  });
5082
5250
  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`);
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`);
5084
5252
  }
5085
5253
  // DOM-only-advance veto (opt-in). A rephrase can turn an advance/"Next" step
5086
5254
  // into a field click (e.g. "click the Yes radio for '18?' then Next"), which
@@ -5106,7 +5274,7 @@ async function executeStepWithHealing(params) {
5106
5274
  ? domVerified
5107
5275
  : false;
5108
5276
  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`);
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`);
5110
5278
  }
5111
5279
  let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep;
5112
5280
  // Final-step submit-verification gate. Replaces the deterministic
@@ -5208,7 +5376,7 @@ async function executeStepWithHealing(params) {
5208
5376
  // Any-4xx fallback. The judge said failed; surface any captured 4xx
5209
5377
  // body's field-level error JSON for the rephrase prompt downstream
5210
5378
  // — same role as before the migration.
5211
- 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");
5212
5380
  if (fallbackEvidence.length > 0) {
5213
5381
  failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
5214
5382
  }
@@ -5348,7 +5516,7 @@ async function executeStepWithHealing(params) {
5348
5516
  retryNetworkIsRealAdvance,
5349
5517
  });
5350
5518
  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`);
5519
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
5352
5520
  }
5353
5521
  let retryVerified = !clickBlockedByInvalid &&
5354
5522
  !fallbackDomOnlyAdvance &&
@@ -5426,7 +5594,7 @@ async function executeStepWithHealing(params) {
5426
5594
  failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
5427
5595
  }
5428
5596
  }
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}`);
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}`);
5430
5598
  if (retryVerified) {
5431
5599
  if (record.verifiedBy === null) {
5432
5600
  record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
@@ -5434,24 +5602,24 @@ async function executeStepWithHealing(params) {
5434
5602
  record.post = retryPost;
5435
5603
  attempts.push(record);
5436
5604
  if (attempt > 1) {
5437
- 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`);
5438
5606
  }
5439
5607
  else {
5440
- 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`);
5441
5609
  }
5442
5610
  trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
5443
5611
  return "completed";
5444
5612
  }
5445
5613
  }
5446
5614
  catch (probeErr) {
5447
- 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)}`);
5448
5616
  }
5449
5617
  }
5450
5618
  }
5451
5619
  attempts.push(record);
5452
5620
  if (verified) {
5453
5621
  if (attempt > 1) {
5454
- 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})`);
5455
5623
  }
5456
5624
  else {
5457
5625
  // Why log first-try wins explicitly: prior to this change, attempt-1
@@ -5461,19 +5629,29 @@ async function executeStepWithHealing(params) {
5461
5629
  // collapse" (log showed 2 heals) but telemetry calls.ndjson showed
5462
5630
  // 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
5463
5631
  // 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})`);
5632
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
5465
5633
  }
5466
5634
  trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
5467
5635
  return "completed";
5468
5636
  }
5469
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
+ });
5470
5648
  const reason = record.errorMessage
5471
5649
  ? effectSignals
5472
5650
  ? `${record.errorMessage}; ${effectSignals}`
5473
5651
  : record.errorMessage
5474
5652
  : effectSignals || "no observable effect (no network, url, or dom change)";
5475
5653
  failureReasons.push(reason);
5476
- 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}`);
5477
5655
  // One additive strategy among many: when a click on a final-step
5478
5656
  // submit/continue button fails AND the page surfaces a visible
5479
5657
  // <error> sibling next to a touched+dirty ng-invalid wrapper, surface
@@ -5498,7 +5676,7 @@ async function executeStepWithHealing(params) {
5498
5676
  for (const p of pairs) {
5499
5677
  const validationReason = formatValidationRejectedReason(p);
5500
5678
  failureReasons.push(validationReason);
5501
- logger.warn(`step ${stepIndex + 1} ${validationReason}`);
5679
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${validationReason}`);
5502
5680
  }
5503
5681
  }
5504
5682
  // Telemetry-driven early-exit: when attempt 1 on a final-Submit click
@@ -5520,6 +5698,17 @@ async function executeStepWithHealing(params) {
5520
5698
  isAdvanceStep(step) &&
5521
5699
  !urlChanged &&
5522
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
+ }
5523
5712
  const postAttemptInvalidCount = await countNgInvalidContainers(page);
5524
5713
  const earlyExit = isSubmitRevealedInvalid({
5525
5714
  // Treat the canonical submit click as "final" for this predicate
@@ -5535,7 +5724,7 @@ async function executeStepWithHealing(params) {
5535
5724
  if (earlyExit) {
5536
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`;
5537
5726
  failureReasons.push(exitReason);
5538
- logger.warn(`step ${stepIndex + 1} ${exitReason}`);
5727
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
5539
5728
  break;
5540
5729
  }
5541
5730
  // Telemetry-driven early-exit for interior ADVANCE steps: if the Next
@@ -5555,7 +5744,7 @@ async function executeStepWithHealing(params) {
5555
5744
  if (advanceStalled) {
5556
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`;
5557
5746
  failureReasons.push(exitReason);
5558
- logger.warn(`step ${stepIndex + 1} ${exitReason}`);
5747
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
5559
5748
  break;
5560
5749
  }
5561
5750
  }
@@ -5583,9 +5772,9 @@ async function executeStepWithHealing(params) {
5583
5772
  unfocusedObserve,
5584
5773
  }) ?? null;
5585
5774
  if (dumpPath !== null) {
5586
- 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}`);
5587
5776
  }
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");
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");
5589
5778
  }
5590
5779
  /** SPA-readiness gate defaults — mirror the recon CLI's post-navigation wait. */
5591
5780
  const SPA_READINESS_TIMEOUT_MS = 15_000;
@@ -5667,6 +5856,7 @@ async function runHealingFlow(deps) {
5667
5856
  upload: s.upload,
5668
5857
  submitStep: s.submitStep,
5669
5858
  stepIndex: i,
5859
+ totalSteps: () => steps.length,
5670
5860
  phase: "flow",
5671
5861
  signalCounter,
5672
5862
  recentCaptures,