@enricai/barnacle 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (448) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +958 -0
  3. package/dist/api/errors.d.ts +88 -0
  4. package/dist/api/errors.d.ts.map +1 -0
  5. package/dist/api/errors.js +164 -0
  6. package/dist/api/errors.js.map +1 -0
  7. package/dist/api/helpers/envelope.d.ts +13 -0
  8. package/dist/api/helpers/envelope.d.ts.map +1 -0
  9. package/dist/api/helpers/envelope.js +20 -0
  10. package/dist/api/helpers/envelope.js.map +1 -0
  11. package/dist/api/helpers/reply.d.ts +9 -0
  12. package/dist/api/helpers/reply.d.ts.map +1 -0
  13. package/dist/api/helpers/reply.js +14 -0
  14. package/dist/api/helpers/reply.js.map +1 -0
  15. package/dist/api/plugins/auth.d.ts +27 -0
  16. package/dist/api/plugins/auth.d.ts.map +1 -0
  17. package/dist/api/plugins/auth.js +111 -0
  18. package/dist/api/plugins/auth.js.map +1 -0
  19. package/dist/api/plugins/error-handler.d.ts +12 -0
  20. package/dist/api/plugins/error-handler.d.ts.map +1 -0
  21. package/dist/api/plugins/error-handler.js +72 -0
  22. package/dist/api/plugins/error-handler.js.map +1 -0
  23. package/dist/api/plugins/request-context.d.ts +17 -0
  24. package/dist/api/plugins/request-context.d.ts.map +1 -0
  25. package/dist/api/plugins/request-context.js +61 -0
  26. package/dist/api/plugins/request-context.js.map +1 -0
  27. package/dist/api/routes/health.d.ts +55 -0
  28. package/dist/api/routes/health.d.ts.map +1 -0
  29. package/dist/api/routes/health.js +137 -0
  30. package/dist/api/routes/health.js.map +1 -0
  31. package/dist/api/routes/plugins-introspection.d.ts +17 -0
  32. package/dist/api/routes/plugins-introspection.d.ts.map +1 -0
  33. package/dist/api/routes/plugins-introspection.js +13 -0
  34. package/dist/api/routes/plugins-introspection.js.map +1 -0
  35. package/dist/api/schemas/common.d.ts +114 -0
  36. package/dist/api/schemas/common.d.ts.map +1 -0
  37. package/dist/api/schemas/common.js +109 -0
  38. package/dist/api/schemas/common.js.map +1 -0
  39. package/dist/api/schemas/telemetry.d.ts +66 -0
  40. package/dist/api/schemas/telemetry.d.ts.map +1 -0
  41. package/dist/api/schemas/telemetry.js +52 -0
  42. package/dist/api/schemas/telemetry.js.map +1 -0
  43. package/dist/cache/keyed-ttl-cache.d.ts +45 -0
  44. package/dist/cache/keyed-ttl-cache.d.ts.map +1 -0
  45. package/dist/cache/keyed-ttl-cache.js +69 -0
  46. package/dist/cache/keyed-ttl-cache.js.map +1 -0
  47. package/dist/cache/response-cache.d.ts +37 -0
  48. package/dist/cache/response-cache.d.ts.map +1 -0
  49. package/dist/cache/response-cache.js +114 -0
  50. package/dist/cache/response-cache.js.map +1 -0
  51. package/dist/config.d.ts +258 -0
  52. package/dist/config.d.ts.map +1 -0
  53. package/dist/config.js +138 -0
  54. package/dist/config.js.map +1 -0
  55. package/dist/lib/applicant-payload.d.ts +28 -0
  56. package/dist/lib/applicant-payload.d.ts.map +1 -0
  57. package/dist/lib/applicant-payload.js +20 -0
  58. package/dist/lib/applicant-payload.js.map +1 -0
  59. package/dist/lib/application-address.d.ts +16 -0
  60. package/dist/lib/application-address.d.ts.map +1 -0
  61. package/dist/lib/application-address.js +20 -0
  62. package/dist/lib/application-address.js.map +1 -0
  63. package/dist/lib/application-answers.d.ts +151 -0
  64. package/dist/lib/application-answers.d.ts.map +1 -0
  65. package/dist/lib/application-answers.js +95 -0
  66. package/dist/lib/application-answers.js.map +1 -0
  67. package/dist/lib/application-identity.d.ts +18 -0
  68. package/dist/lib/application-identity.d.ts.map +1 -0
  69. package/dist/lib/application-identity.js +20 -0
  70. package/dist/lib/application-identity.js.map +1 -0
  71. package/dist/lib/application-resume.d.ts +21 -0
  72. package/dist/lib/application-resume.d.ts.map +1 -0
  73. package/dist/lib/application-resume.js +23 -0
  74. package/dist/lib/application-resume.js.map +1 -0
  75. package/dist/lib/bedrock.d.ts +22 -0
  76. package/dist/lib/bedrock.d.ts.map +1 -0
  77. package/dist/lib/bedrock.js +35 -0
  78. package/dist/lib/bedrock.js.map +1 -0
  79. package/dist/lib/case-insensitive-headers.d.ts +15 -0
  80. package/dist/lib/case-insensitive-headers.d.ts.map +1 -0
  81. package/dist/lib/case-insensitive-headers.js +29 -0
  82. package/dist/lib/case-insensitive-headers.js.map +1 -0
  83. package/dist/lib/chromium-client-hints.d.ts +11 -0
  84. package/dist/lib/chromium-client-hints.d.ts.map +1 -0
  85. package/dist/lib/chromium-client-hints.js +17 -0
  86. package/dist/lib/chromium-client-hints.js.map +1 -0
  87. package/dist/lib/cookie-value.d.ts +8 -0
  88. package/dist/lib/cookie-value.d.ts.map +1 -0
  89. package/dist/lib/cookie-value.js +14 -0
  90. package/dist/lib/cookie-value.js.map +1 -0
  91. package/dist/lib/datadog.d.ts +8 -0
  92. package/dist/lib/datadog.d.ts.map +1 -0
  93. package/dist/lib/datadog.js +27 -0
  94. package/dist/lib/datadog.js.map +1 -0
  95. package/dist/lib/dd-metrics.d.ts +28 -0
  96. package/dist/lib/dd-metrics.d.ts.map +1 -0
  97. package/dist/lib/dd-metrics.js +62 -0
  98. package/dist/lib/dd-metrics.js.map +1 -0
  99. package/dist/lib/dispatch-metrics.d.ts +19 -0
  100. package/dist/lib/dispatch-metrics.d.ts.map +1 -0
  101. package/dist/lib/dispatch-metrics.js +65 -0
  102. package/dist/lib/dispatch-metrics.js.map +1 -0
  103. package/dist/lib/env.d.ts +37 -0
  104. package/dist/lib/env.d.ts.map +1 -0
  105. package/dist/lib/env.js +70 -0
  106. package/dist/lib/env.js.map +1 -0
  107. package/dist/lib/errors.d.ts +15 -0
  108. package/dist/lib/errors.d.ts.map +1 -0
  109. package/dist/lib/errors.js +20 -0
  110. package/dist/lib/errors.js.map +1 -0
  111. package/dist/lib/http.d.ts +7 -0
  112. package/dist/lib/http.d.ts.map +1 -0
  113. package/dist/lib/http.js +14 -0
  114. package/dist/lib/http.js.map +1 -0
  115. package/dist/lib/job-tracking.d.ts +17 -0
  116. package/dist/lib/job-tracking.d.ts.map +1 -0
  117. package/dist/lib/job-tracking.js +19 -0
  118. package/dist/lib/job-tracking.js.map +1 -0
  119. package/dist/lib/llm/anthropic-client.d.ts +12 -0
  120. package/dist/lib/llm/anthropic-client.d.ts.map +1 -0
  121. package/dist/lib/llm/anthropic-client.js +23 -0
  122. package/dist/lib/llm/anthropic-client.js.map +1 -0
  123. package/dist/lib/llm/judge.d.ts +57 -0
  124. package/dist/lib/llm/judge.d.ts.map +1 -0
  125. package/dist/lib/llm/judge.js +123 -0
  126. package/dist/lib/llm/judge.js.map +1 -0
  127. package/dist/lib/llm/judges/error-messages.d.ts +38 -0
  128. package/dist/lib/llm/judges/error-messages.d.ts.map +1 -0
  129. package/dist/lib/llm/judges/error-messages.js +72 -0
  130. package/dist/lib/llm/judges/error-messages.js.map +1 -0
  131. package/dist/lib/llm/judges/invalid-fields.d.ts +43 -0
  132. package/dist/lib/llm/judges/invalid-fields.d.ts.map +1 -0
  133. package/dist/lib/llm/judges/invalid-fields.js +80 -0
  134. package/dist/lib/llm/judges/invalid-fields.js.map +1 -0
  135. package/dist/lib/llm/judges/modal-priority.d.ts +41 -0
  136. package/dist/lib/llm/judges/modal-priority.d.ts.map +1 -0
  137. package/dist/lib/llm/judges/modal-priority.js +74 -0
  138. package/dist/lib/llm/judges/modal-priority.js.map +1 -0
  139. package/dist/lib/llm/judges/select-option.d.ts +49 -0
  140. package/dist/lib/llm/judges/select-option.d.ts.map +1 -0
  141. package/dist/lib/llm/judges/select-option.js +90 -0
  142. package/dist/lib/llm/judges/select-option.js.map +1 -0
  143. package/dist/lib/llm/judges/verify-submit.d.ts +68 -0
  144. package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -0
  145. package/dist/lib/llm/judges/verify-submit.js +89 -0
  146. package/dist/lib/llm/judges/verify-submit.js.map +1 -0
  147. package/dist/lib/llm/schemas.d.ts +220 -0
  148. package/dist/lib/llm/schemas.d.ts.map +1 -0
  149. package/dist/lib/llm/schemas.js +239 -0
  150. package/dist/lib/llm/schemas.js.map +1 -0
  151. package/dist/lib/logging.d.ts +39 -0
  152. package/dist/lib/logging.d.ts.map +1 -0
  153. package/dist/lib/logging.js +219 -0
  154. package/dist/lib/logging.js.map +1 -0
  155. package/dist/lib/multipart.d.ts +34 -0
  156. package/dist/lib/multipart.d.ts.map +1 -0
  157. package/dist/lib/multipart.js +55 -0
  158. package/dist/lib/multipart.js.map +1 -0
  159. package/dist/lib/option-matcher.d.ts +15 -0
  160. package/dist/lib/option-matcher.d.ts.map +1 -0
  161. package/dist/lib/option-matcher.js +69 -0
  162. package/dist/lib/option-matcher.js.map +1 -0
  163. package/dist/lib/phone.d.ts +11 -0
  164. package/dist/lib/phone.d.ts.map +1 -0
  165. package/dist/lib/phone.js +17 -0
  166. package/dist/lib/phone.js.map +1 -0
  167. package/dist/lib/random.d.ts +22 -0
  168. package/dist/lib/random.d.ts.map +1 -0
  169. package/dist/lib/random.js +33 -0
  170. package/dist/lib/random.js.map +1 -0
  171. package/dist/lib/statsd.d.ts +11 -0
  172. package/dist/lib/statsd.d.ts.map +1 -0
  173. package/dist/lib/statsd.js +68 -0
  174. package/dist/lib/statsd.js.map +1 -0
  175. package/dist/lib/telemetry/call-capture.d.ts +89 -0
  176. package/dist/lib/telemetry/call-capture.d.ts.map +1 -0
  177. package/dist/lib/telemetry/call-capture.js +190 -0
  178. package/dist/lib/telemetry/call-capture.js.map +1 -0
  179. package/dist/lib/telemetry/call-types.d.ts +65 -0
  180. package/dist/lib/telemetry/call-types.d.ts.map +1 -0
  181. package/dist/lib/telemetry/call-types.js +68 -0
  182. package/dist/lib/telemetry/call-types.js.map +1 -0
  183. package/dist/lib/telemetry/run-state.d.ts +15 -0
  184. package/dist/lib/telemetry/run-state.d.ts.map +1 -0
  185. package/dist/lib/telemetry/run-state.js +23 -0
  186. package/dist/lib/telemetry/run-state.js.map +1 -0
  187. package/dist/lib/telemetry/s3-sink.d.ts +48 -0
  188. package/dist/lib/telemetry/s3-sink.d.ts.map +1 -0
  189. package/dist/lib/telemetry/s3-sink.js +229 -0
  190. package/dist/lib/telemetry/s3-sink.js.map +1 -0
  191. package/dist/lib/telemetry/submission-capture.d.ts +45 -0
  192. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -0
  193. package/dist/lib/telemetry/submission-capture.js +57 -0
  194. package/dist/lib/telemetry/submission-capture.js.map +1 -0
  195. package/dist/lib/telemetry/telemetry-paths.d.ts +59 -0
  196. package/dist/lib/telemetry/telemetry-paths.d.ts.map +1 -0
  197. package/dist/lib/telemetry/telemetry-paths.js +103 -0
  198. package/dist/lib/telemetry/telemetry-paths.js.map +1 -0
  199. package/dist/lib/tracking-click.d.ts +20 -0
  200. package/dist/lib/tracking-click.d.ts.map +1 -0
  201. package/dist/lib/tracking-click.js +75 -0
  202. package/dist/lib/tracking-click.js.map +1 -0
  203. package/dist/lib/url-capture.d.ts +8 -0
  204. package/dist/lib/url-capture.d.ts.map +1 -0
  205. package/dist/lib/url-capture.js +17 -0
  206. package/dist/lib/url-capture.js.map +1 -0
  207. package/dist/lib/us-states.d.ts +20 -0
  208. package/dist/lib/us-states.d.ts.map +1 -0
  209. package/dist/lib/us-states.js +86 -0
  210. package/dist/lib/us-states.js.map +1 -0
  211. package/dist/lib/zod-multipart.d.ts +18 -0
  212. package/dist/lib/zod-multipart.d.ts.map +1 -0
  213. package/dist/lib/zod-multipart.js +35 -0
  214. package/dist/lib/zod-multipart.js.map +1 -0
  215. package/dist/plugins/config-plugin.d.ts +77 -0
  216. package/dist/plugins/config-plugin.d.ts.map +1 -0
  217. package/dist/plugins/config-plugin.js +216 -0
  218. package/dist/plugins/config-plugin.js.map +1 -0
  219. package/dist/plugins/discover.d.ts +72 -0
  220. package/dist/plugins/discover.d.ts.map +1 -0
  221. package/dist/plugins/discover.js +265 -0
  222. package/dist/plugins/discover.js.map +1 -0
  223. package/dist/plugins/json-schema-to-zod.d.ts +47 -0
  224. package/dist/plugins/json-schema-to-zod.d.ts.map +1 -0
  225. package/dist/plugins/json-schema-to-zod.js +85 -0
  226. package/dist/plugins/json-schema-to-zod.js.map +1 -0
  227. package/dist/plugins/loader.d.ts +34 -0
  228. package/dist/plugins/loader.d.ts.map +1 -0
  229. package/dist/plugins/loader.js +348 -0
  230. package/dist/plugins/loader.js.map +1 -0
  231. package/dist/plugins/plugin-api-version.d.ts +8 -0
  232. package/dist/plugins/plugin-api-version.d.ts.map +1 -0
  233. package/dist/plugins/plugin-api-version.js +11 -0
  234. package/dist/plugins/plugin-api-version.js.map +1 -0
  235. package/dist/plugins/plugin-manifest-envelope.d.ts +16 -0
  236. package/dist/plugins/plugin-manifest-envelope.d.ts.map +1 -0
  237. package/dist/plugins/plugin-manifest-envelope.js +19 -0
  238. package/dist/plugins/plugin-manifest-envelope.js.map +1 -0
  239. package/dist/scraper/behavioral-signals.d.ts +16 -0
  240. package/dist/scraper/behavioral-signals.d.ts.map +1 -0
  241. package/dist/scraper/behavioral-signals.js +30 -0
  242. package/dist/scraper/behavioral-signals.js.map +1 -0
  243. package/dist/scraper/errors.d.ts +193 -0
  244. package/dist/scraper/errors.d.ts.map +1 -0
  245. package/dist/scraper/errors.js +226 -0
  246. package/dist/scraper/errors.js.map +1 -0
  247. package/dist/scraper/fixtures.d.ts +23 -0
  248. package/dist/scraper/fixtures.d.ts.map +1 -0
  249. package/dist/scraper/fixtures.js +48 -0
  250. package/dist/scraper/fixtures.js.map +1 -0
  251. package/dist/scraper/flow-runner.d.ts +1319 -0
  252. package/dist/scraper/flow-runner.d.ts.map +1 -0
  253. package/dist/scraper/flow-runner.js +5637 -0
  254. package/dist/scraper/flow-runner.js.map +1 -0
  255. package/dist/scraper/graphql-client.d.ts +30 -0
  256. package/dist/scraper/graphql-client.d.ts.map +1 -0
  257. package/dist/scraper/graphql-client.js +14 -0
  258. package/dist/scraper/graphql-client.js.map +1 -0
  259. package/dist/scraper/http-client.d.ts +75 -0
  260. package/dist/scraper/http-client.d.ts.map +1 -0
  261. package/dist/scraper/http-client.js +189 -0
  262. package/dist/scraper/http-client.js.map +1 -0
  263. package/dist/scraper/http-status-classifier.d.ts +12 -0
  264. package/dist/scraper/http-status-classifier.d.ts.map +1 -0
  265. package/dist/scraper/http-status-classifier.js +29 -0
  266. package/dist/scraper/http-status-classifier.js.map +1 -0
  267. package/dist/scraper/metrics.d.ts +50 -0
  268. package/dist/scraper/metrics.d.ts.map +1 -0
  269. package/dist/scraper/metrics.js +85 -0
  270. package/dist/scraper/metrics.js.map +1 -0
  271. package/dist/scraper/navigate.d.ts +20 -0
  272. package/dist/scraper/navigate.d.ts.map +1 -0
  273. package/dist/scraper/navigate.js +30 -0
  274. package/dist/scraper/navigate.js.map +1 -0
  275. package/dist/scraper/oracle-sentinels.d.ts +22 -0
  276. package/dist/scraper/oracle-sentinels.d.ts.map +1 -0
  277. package/dist/scraper/oracle-sentinels.js +40 -0
  278. package/dist/scraper/oracle-sentinels.js.map +1 -0
  279. package/dist/scraper/parse-json-response.d.ts +16 -0
  280. package/dist/scraper/parse-json-response.d.ts.map +1 -0
  281. package/dist/scraper/parse-json-response.js +37 -0
  282. package/dist/scraper/parse-json-response.js.map +1 -0
  283. package/dist/scraper/pool.d.ts +37 -0
  284. package/dist/scraper/pool.d.ts.map +1 -0
  285. package/dist/scraper/pool.js +105 -0
  286. package/dist/scraper/pool.js.map +1 -0
  287. package/dist/scraper/rate-limited-json-client.d.ts +35 -0
  288. package/dist/scraper/rate-limited-json-client.d.ts.map +1 -0
  289. package/dist/scraper/rate-limited-json-client.js +29 -0
  290. package/dist/scraper/rate-limited-json-client.js.map +1 -0
  291. package/dist/scraper/raw-fetch.d.ts +49 -0
  292. package/dist/scraper/raw-fetch.d.ts.map +1 -0
  293. package/dist/scraper/raw-fetch.js +40 -0
  294. package/dist/scraper/raw-fetch.js.map +1 -0
  295. package/dist/scraper/require-response-field.d.ts +23 -0
  296. package/dist/scraper/require-response-field.d.ts.map +1 -0
  297. package/dist/scraper/require-response-field.js +42 -0
  298. package/dist/scraper/require-response-field.js.map +1 -0
  299. package/dist/scraper/retry.d.ts +33 -0
  300. package/dist/scraper/retry.d.ts.map +1 -0
  301. package/dist/scraper/retry.js +122 -0
  302. package/dist/scraper/retry.js.map +1 -0
  303. package/dist/scraper/session-browserbase.d.ts +35 -0
  304. package/dist/scraper/session-browserbase.d.ts.map +1 -0
  305. package/dist/scraper/session-browserbase.js +198 -0
  306. package/dist/scraper/session-browserbase.js.map +1 -0
  307. package/dist/scraper/session-shared.d.ts +29 -0
  308. package/dist/scraper/session-shared.d.ts.map +1 -0
  309. package/dist/scraper/session-shared.js +35 -0
  310. package/dist/scraper/session-shared.js.map +1 -0
  311. package/dist/scraper/session-steel.d.ts +31 -0
  312. package/dist/scraper/session-steel.d.ts.map +1 -0
  313. package/dist/scraper/session-steel.js +140 -0
  314. package/dist/scraper/session-steel.js.map +1 -0
  315. package/dist/scraper/session-warmup.d.ts +31 -0
  316. package/dist/scraper/session-warmup.d.ts.map +1 -0
  317. package/dist/scraper/session-warmup.js +46 -0
  318. package/dist/scraper/session-warmup.js.map +1 -0
  319. package/dist/scraper/session.d.ts +21 -0
  320. package/dist/scraper/session.d.ts.map +1 -0
  321. package/dist/scraper/session.js +28 -0
  322. package/dist/scraper/session.js.map +1 -0
  323. package/dist/scraper/stagehand-guard.d.ts +128 -0
  324. package/dist/scraper/stagehand-guard.d.ts.map +1 -0
  325. package/dist/scraper/stagehand-guard.js +337 -0
  326. package/dist/scraper/stagehand-guard.js.map +1 -0
  327. package/dist/scraper/throttle.d.ts +21 -0
  328. package/dist/scraper/throttle.d.ts.map +1 -0
  329. package/dist/scraper/throttle.js +45 -0
  330. package/dist/scraper/throttle.js.map +1 -0
  331. package/dist/scripts/hca-inbox-poll.d.ts +19 -0
  332. package/dist/scripts/hca-inbox-poll.d.ts.map +1 -0
  333. package/dist/scripts/hca-inbox-poll.js +151 -0
  334. package/dist/scripts/hca-inbox-poll.js.map +1 -0
  335. package/dist/scripts/judge-llm-batch.d.ts +98 -0
  336. package/dist/scripts/judge-llm-batch.d.ts.map +1 -0
  337. package/dist/scripts/judge-llm-batch.js +350 -0
  338. package/dist/scripts/judge-llm-batch.js.map +1 -0
  339. package/dist/scripts/llm-heal.d.ts +165 -0
  340. package/dist/scripts/llm-heal.d.ts.map +1 -0
  341. package/dist/scripts/llm-heal.js +595 -0
  342. package/dist/scripts/llm-heal.js.map +1 -0
  343. package/dist/scripts/migrate-telemetry-dir-names.d.ts +57 -0
  344. package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +1 -0
  345. package/dist/scripts/migrate-telemetry-dir-names.js +156 -0
  346. package/dist/scripts/migrate-telemetry-dir-names.js.map +1 -0
  347. package/dist/scripts/probe-setinputfiles.d.ts +28 -0
  348. package/dist/scripts/probe-setinputfiles.d.ts.map +1 -0
  349. package/dist/scripts/probe-setinputfiles.js +95 -0
  350. package/dist/scripts/probe-setinputfiles.js.map +1 -0
  351. package/dist/scripts/recon-browser.d.ts +304 -0
  352. package/dist/scripts/recon-browser.d.ts.map +1 -0
  353. package/dist/scripts/recon-browser.js +1833 -0
  354. package/dist/scripts/recon-browser.js.map +1 -0
  355. package/dist/scripts/recon-generate.d.ts +182 -0
  356. package/dist/scripts/recon-generate.d.ts.map +1 -0
  357. package/dist/scripts/recon-generate.js +2572 -0
  358. package/dist/scripts/recon-generate.js.map +1 -0
  359. package/dist/scripts/recon-heal.d.ts +176 -0
  360. package/dist/scripts/recon-heal.d.ts.map +1 -0
  361. package/dist/scripts/recon-heal.js +584 -0
  362. package/dist/scripts/recon-heal.js.map +1 -0
  363. package/dist/scripts/recon-http.d.ts +19 -0
  364. package/dist/scripts/recon-http.d.ts.map +1 -0
  365. package/dist/scripts/recon-http.js +377 -0
  366. package/dist/scripts/recon-http.js.map +1 -0
  367. package/dist/scripts/recon-replay-jobs.d.ts +21 -0
  368. package/dist/scripts/recon-replay-jobs.d.ts.map +1 -0
  369. package/dist/scripts/recon-replay-jobs.js +221 -0
  370. package/dist/scripts/recon-replay-jobs.js.map +1 -0
  371. package/dist/scripts/recon-shared.d.ts +52 -0
  372. package/dist/scripts/recon-shared.d.ts.map +1 -0
  373. package/dist/scripts/recon-shared.js +68 -0
  374. package/dist/scripts/recon-shared.js.map +1 -0
  375. package/dist/scripts/recon-summarize.d.ts +15 -0
  376. package/dist/scripts/recon-summarize.d.ts.map +1 -0
  377. package/dist/scripts/recon-summarize.js +233 -0
  378. package/dist/scripts/recon-summarize.js.map +1 -0
  379. package/dist/scripts/smoke-test.d.ts +22 -0
  380. package/dist/scripts/smoke-test.d.ts.map +1 -0
  381. package/dist/scripts/smoke-test.js +236 -0
  382. package/dist/scripts/smoke-test.js.map +1 -0
  383. package/dist/server.d.ts +10 -0
  384. package/dist/server.d.ts.map +1 -0
  385. package/dist/server.js +189 -0
  386. package/dist/server.js.map +1 -0
  387. package/dist/site-plugin.d.ts +226 -0
  388. package/dist/site-plugin.d.ts.map +1 -0
  389. package/dist/site-plugin.js +13 -0
  390. package/dist/site-plugin.js.map +1 -0
  391. package/dist/testing/answers-fixture.d.ts +13 -0
  392. package/dist/testing/answers-fixture.d.ts.map +1 -0
  393. package/dist/testing/answers-fixture.js +37 -0
  394. package/dist/testing/answers-fixture.js.map +1 -0
  395. package/dist/testing/batch-email-confirmation.d.ts +58 -0
  396. package/dist/testing/batch-email-confirmation.d.ts.map +1 -0
  397. package/dist/testing/batch-email-confirmation.js +62 -0
  398. package/dist/testing/batch-email-confirmation.js.map +1 -0
  399. package/dist/testing/batch-report.d.ts +33 -0
  400. package/dist/testing/batch-report.d.ts.map +1 -0
  401. package/dist/testing/batch-report.js +30 -0
  402. package/dist/testing/batch-report.js.map +1 -0
  403. package/dist/testing/contract-parity-suite.d.ts +55 -0
  404. package/dist/testing/contract-parity-suite.d.ts.map +1 -0
  405. package/dist/testing/contract-parity-suite.js +39 -0
  406. package/dist/testing/contract-parity-suite.js.map +1 -0
  407. package/dist/testing/coverage-guard-suite.d.ts +46 -0
  408. package/dist/testing/coverage-guard-suite.d.ts.map +1 -0
  409. package/dist/testing/coverage-guard-suite.js +39 -0
  410. package/dist/testing/coverage-guard-suite.js.map +1 -0
  411. package/dist/testing/fixtures/resume.pdf +54 -0
  412. package/dist/testing/integration-runner.d.ts +62 -0
  413. package/dist/testing/integration-runner.d.ts.map +1 -0
  414. package/dist/testing/integration-runner.js +46 -0
  415. package/dist/testing/integration-runner.js.map +1 -0
  416. package/dist/testing/mock-fetch-response.d.ts +7 -0
  417. package/dist/testing/mock-fetch-response.d.ts.map +1 -0
  418. package/dist/testing/mock-fetch-response.js +16 -0
  419. package/dist/testing/mock-fetch-response.js.map +1 -0
  420. package/dist/testing/persona-fixture.d.ts +77 -0
  421. package/dist/testing/persona-fixture.d.ts.map +1 -0
  422. package/dist/testing/persona-fixture.js +83 -0
  423. package/dist/testing/persona-fixture.js.map +1 -0
  424. package/dist/testing/replay-integration-suite.d.ts +50 -0
  425. package/dist/testing/replay-integration-suite.d.ts.map +1 -0
  426. package/dist/testing/replay-integration-suite.js +45 -0
  427. package/dist/testing/replay-integration-suite.js.map +1 -0
  428. package/dist/testing/resume-fixture.d.ts +39 -0
  429. package/dist/testing/resume-fixture.d.ts.map +1 -0
  430. package/dist/testing/resume-fixture.js +38 -0
  431. package/dist/testing/resume-fixture.js.map +1 -0
  432. package/dist/testmail/client.d.ts +61 -0
  433. package/dist/testmail/client.d.ts.map +1 -0
  434. package/dist/testmail/client.js +190 -0
  435. package/dist/testmail/client.js.map +1 -0
  436. package/dist/testmail/errors.d.ts +18 -0
  437. package/dist/testmail/errors.d.ts.map +1 -0
  438. package/dist/testmail/errors.js +29 -0
  439. package/dist/testmail/errors.js.map +1 -0
  440. package/dist/types/dispatch-metrics.d.ts +46 -0
  441. package/dist/types/dispatch-metrics.d.ts.map +1 -0
  442. package/dist/types/dispatch-metrics.js +25 -0
  443. package/dist/types/dispatch-metrics.js.map +1 -0
  444. package/dist/types/logging.d.ts +11 -0
  445. package/dist/types/logging.d.ts.map +1 -0
  446. package/dist/types/logging.js +3 -0
  447. package/dist/types/logging.js.map +1 -0
  448. package/package.json +283 -0
@@ -0,0 +1,1833 @@
1
+ "use strict";
2
+ /**
3
+ * Phase 1 recon: drives a real browser through a user-defined flow while
4
+ * wiretapping every network response. Captures are written to
5
+ * /tmp/recon/graphql/<NNN>-<phase>-<operationName>.json — one file per call,
6
+ * diffable and greppable.
7
+ *
8
+ * Recovery model — each flow step runs through a 4-attempt self-healing cascade
9
+ * (act → observe+act → observe+act with ignoreSelectors → Anthropic-SDK rephrase),
10
+ * verified by "did the network counter advance OR did the URL change". On terminal
11
+ * cascade failure the step is dumped to /tmp/recon/step-failures/ and the script's
12
+ * main() loop attempts up to MAX_REPLANS=2 global replans, where Claude rewrites
13
+ * the remaining flow tail given the failure context. Bedrock-only deployments
14
+ * skip the LLM-rephrase attempt and the replan loop with a startup warn. See
15
+ * docs/playbook.md sections 1c–1e for the full design.
16
+ *
17
+ * Usage:
18
+ * pnpm tsx src/scripts/recon-browser.ts \
19
+ * --url https://example.com \
20
+ * --flow '["click the category filter", "open the first product"]'
21
+ *
22
+ * # Or load the flow from a committed file (preferred — makes recon re-runnable):
23
+ * pnpm tsx src/scripts/recon-browser.ts \
24
+ * --url https://example.com \
25
+ * --flow-file src/sites/my-site/recon-flow.json
26
+ *
27
+ * # Captures every network response — no URL-shape filtering. Use grep
28
+ * # against /tmp/recon/graphql/ if you only want specific endpoints.
29
+ * pnpm tsx src/scripts/recon-browser.ts --url https://example.com
30
+ *
31
+ * The script needs STEEL_API_KEY and either ANTHROPIC_API_KEY or USE_BEDROCK=true
32
+ * in the environment (same vars as the main server).
33
+ *
34
+ * Runtime: varies — ~20–40 min for a full flow (STEP_PAUSE_MS × N steps + LLM latency
35
+ * per act; healing and replans push the upper bound higher on flaky sites).
36
+ */
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.writeFixtureToTempFile = exports.windowHasTransitionBody = exports.windowHasAdvanceTransition = exports.waitForTransitionBody = exports.verifyFillReadback = exports.shouldVetoFallbackAdvance = exports.shouldSkipTechnique = exports.selectRadioGroupOption = exports.selectBodyExcerpt = exports.resetBillingErrorFlagForTests = exports.rephraseWithLLM = exports.renderUnfocusedObserve = exports.renderLeafInvalidFields = exports.probeStepBeforeAttempts = exports.probeLeafInvalidContainers = exports.pollEnumerate = exports.parseSelectStep = exports.parseRadioStep = exports.parseCaptureIndex = exports.pairInvalidWithErrors = exports.normalizeDateValue = exports.narrowInvalidFormControl = exports.logBillingErrorIfPresent = exports.latestCaptureIndex = exports.isWizardExitAction = exports.isUploadAffordanceLabel = exports.isSubmitRevealedInvalid = exports.isDomOnlyAdvanceVerified = exports.isAdvanceStep = exports.isAdvanceStalled = exports.hasBillingErrorBeenLogged = exports.formatValidationRejectedReason = exports.findRecentPageTransition = exports.findRecentBackendError = exports.fillHtml5DateTimeInput = exports.extractSubmitFailureEvidence = exports.extractGaEventEvidence = exports.describeAttemptEffectSignals = exports.chooseRequiredSelectOption = exports.capturesAfterIndex = exports.buildRadioIdXPath = void 0;
39
+ exports.detectRejectionInResponseBody = detectRejectionInResponseBody;
40
+ exports.findWizardRestartSignal = findWizardRestartSignal;
41
+ exports.isStructurallyBlocked = isStructurallyBlocked;
42
+ exports.summarizeReplanFailureKinds = summarizeReplanFailureKinds;
43
+ exports.isReplanCycle = isReplanCycle;
44
+ exports.filterCompletedFromReplan = filterCompletedFromReplan;
45
+ exports.isReplanReproposingFailedStep = isReplanReproposingFailedStep;
46
+ exports.renderStepWindow = renderStepWindow;
47
+ exports.countSlugPrefixMatches = countSlugPrefixMatches;
48
+ exports.dedupeConsecutiveIdentical = dedupeConsecutiveIdentical;
49
+ exports.denormalizeStep = denormalizeStep;
50
+ exports.persistReplannedFlow = persistReplannedFlow;
51
+ exports.readFailureDumpEvidence = readFailureDumpEvidence;
52
+ exports.replanRemainingFlow = replanRemainingFlow;
53
+ const node_crypto_1 = require("node:crypto");
54
+ const node_fs_1 = require("node:fs");
55
+ const node_path_1 = require("node:path");
56
+ const zod_1 = require("@anthropic-ai/sdk/helpers/zod");
57
+ const date_fns_1 = require("date-fns");
58
+ const v4_1 = require("zod/v4");
59
+ const errors_1 = require("../lib/errors");
60
+ const http_1 = require("../lib/http");
61
+ const anthropic_client_1 = require("../lib/llm/anthropic-client");
62
+ const error_messages_1 = require("../lib/llm/judges/error-messages");
63
+ const invalid_fields_1 = require("../lib/llm/judges/invalid-fields");
64
+ const verify_submit_1 = require("../lib/llm/judges/verify-submit");
65
+ const schemas_1 = require("../lib/llm/schemas");
66
+ const logging_1 = require("../lib/logging");
67
+ const call_capture_1 = require("../lib/telemetry/call-capture");
68
+ const call_types_1 = require("../lib/telemetry/call-types");
69
+ const telemetry_paths_1 = require("../lib/telemetry/telemetry-paths");
70
+ const errors_2 = require("../scraper/errors");
71
+ const flow_runner_1 = require("../scraper/flow-runner");
72
+ const session_1 = require("../scraper/session");
73
+ const stagehand_guard_1 = require("../scraper/stagehand-guard");
74
+ const judge_llm_batch_1 = require("../scripts/judge-llm-batch");
75
+ const recon_shared_1 = require("../scripts/recon-shared");
76
+ const client_1 = require("../testmail/client");
77
+ (0, http_1.configureHttpDispatcher)();
78
+ const logger = (0, logging_1.getScriptLogger)("recon-browser");
79
+ /**
80
+ * Identical-proposal threshold for {@link isReplanCycle}. Set to a value
81
+ * high enough that one repeat plus one retry under the page-state guard
82
+ * doesn't trip — only a sustained fixed point should. Below 3, legitimate
83
+ * "same proposal under slightly-different state" retries can produce false
84
+ * cycle detections.
85
+ */
86
+ const REPLAN_CYCLE_THRESHOLD = 3;
87
+ /**
88
+ * Maximum bodyHtmlLength delta (in chars) at which two replan attempts are
89
+ * treated as targeting the same page state. Separates incidental DOM churn
90
+ * (framework attribute updates, focus rings, single-element re-renders;
91
+ * typically single-to-low-double-digit char deltas) from real page
92
+ * transitions (typically kilobyte-scale once new sections render).
93
+ */
94
+ const HTML_STATIC_TOLERANCE = 100;
95
+ /**
96
+ * Thin CLI wrapper over {@link wireSignalCapture}: owns the on-disk capture
97
+ * layout (writes each capture — and its decoded-params sidecar — under
98
+ * `CAPTURES_DIR`) while delegating all in-memory capture bookkeeping to the
99
+ * shared flow-runner engine. Kept here so the persistence policy stays with
100
+ * the recon entry-point and `flow-runner.ts` remains filesystem-agnostic.
101
+ */
102
+ function wireNetworkCapture(page, counter, signalCounter, recentCaptures, recentCaptureMeta, getCurrentPhase, getCurrentPageOrigin) {
103
+ return (0, flow_runner_1.wireSignalCapture)(page, {
104
+ counter,
105
+ signalCounter,
106
+ recentCaptures,
107
+ recentCaptureMeta,
108
+ getCurrentPhase,
109
+ getCurrentPageOrigin,
110
+ onCapture: (capture, filename) => {
111
+ // Defense in depth: a future edge case (NFS, symlink loop, full disk)
112
+ // shouldn't crash the whole recon run. Drop the capture, log loudly,
113
+ // and let the cascade continue. The capture is forensic-only — the
114
+ // happy-path doesn't read these files until something else fails.
115
+ try {
116
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(recon_shared_1.CAPTURES_DIR, filename), JSON.stringify(capture, null, 2));
117
+ if (capture.decodedParams !== null && capture.decodedParams !== capture.requestPostData) {
118
+ const decodedFilename = filename.replace(/\.json$/, ".decoded.json");
119
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(recon_shared_1.CAPTURES_DIR, decodedFilename), JSON.stringify(capture.decodedParams, null, 2));
120
+ }
121
+ }
122
+ catch (err) {
123
+ logger.warn(`capture-write skipped for ${capture.url.slice(0, 80)}: ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ },
126
+ });
127
+ }
128
+ const MAX_PROBE_REPLANS = 5;
129
+ /**
130
+ * Replans triggered by the full self-healing cascade exhausting all 4
131
+ * attempts (expensive: 4 attempts × backoff + LLM rephrase + observe
132
+ * calls). Counted separately from probe replans so cheap recoveries don't
133
+ * consume the budget reserved for expensive recoveries.
134
+ */
135
+ const MAX_CASCADE_REPLANS = 5;
136
+ function detectRejectionInResponseBody(body) {
137
+ if (!body || typeof body !== "object")
138
+ return { rejected: false, reason: null };
139
+ const rec = body;
140
+ if (rec.not_qualified === true) {
141
+ return {
142
+ rejected: true,
143
+ reason: typeof rec.error === "string" ? rec.error : "not_qualified",
144
+ };
145
+ }
146
+ if (rec.rejected === true) {
147
+ return {
148
+ rejected: true,
149
+ reason: typeof rec.reason === "string" ? rec.reason : "rejected",
150
+ };
151
+ }
152
+ if (rec.qualified === false) {
153
+ return {
154
+ rejected: true,
155
+ reason: typeof rec.reason === "string" ? rec.reason : "qualified=false",
156
+ };
157
+ }
158
+ if (typeof rec.status === "string" && rec.status === "rejected") {
159
+ return {
160
+ rejected: true,
161
+ reason: typeof rec.reason === "string" ? rec.reason : "status=rejected",
162
+ };
163
+ }
164
+ return { rejected: false, reason: null };
165
+ }
166
+ /**
167
+ * Normalize a capture's responseBody to the parsed-object shape that
168
+ * `detectRejectionInResponseBody` expects. Capture writer at
169
+ * recon-browser.ts:240 stores the field as either:
170
+ * - a parsed OBJECT (the common case — JSON.parse succeeded at capture time)
171
+ * - a STRING (fallback when JSON.parse failed — raw text body)
172
+ * - null (CDP body fetch failed; binary; unavailable)
173
+ *
174
+ * Previous version of this helper assumed string-only and returned null for
175
+ * the object case — causing Q1's audit to miss 100% of rejection envelopes
176
+ * because AppCast (and any JSON-serving ATS) lands in the object case.
177
+ * Verified 2026-06-15 against capture 122-...-a4ab5256.json:
178
+ * `jq '.responseBody | type'` returned `"object"`, the broken helper
179
+ * returned null, the audit declared "PASSED" while the body contained
180
+ * `{"not_qualified": true}`.
181
+ *
182
+ * Site-agnostic: every JSON-serving REST endpoint produces the object
183
+ * case; the string-as-JSON fallback covers rare cases where the capture
184
+ * writer stored a parseable JSON string for some reason.
185
+ */
186
+ function normalizeResponseBodyForAudit(data) {
187
+ const body = data.responseBody;
188
+ if (body && typeof body === "object")
189
+ return body;
190
+ if (typeof body === "string") {
191
+ try {
192
+ return JSON.parse(body);
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+ /**
201
+ * End-of-run audit: scan ALL captures written by this run for any 2xx
202
+ * whose URL matches the flow's submitEndpointPattern AND whose response
203
+ * body does NOT indicate a rejection envelope. Returns true when NO clean
204
+ * 2xx match is found — i.e. the run completed without an accepted
205
+ * submission landing. Caller exits non-zero so silent-pass states surface
206
+ * as real failures.
207
+ *
208
+ * The audit is independent of the per-step verifier — the verifier may
209
+ * have accepted a DOM-fallback or URL-change signal as proof, but if
210
+ * the configured submit endpoint never returned 2xx, OR returned 2xx
211
+ * with a rejection envelope (e.g. AppCast's `not_qualified: true`), the
212
+ * application data didn't actually reach the employer's ATS.
213
+ *
214
+ * Site-agnostic: rejection detection is via `detectRejectionInResponseBody`
215
+ * which knows the union of common ATS rejection-envelope shapes
216
+ * (AppCast/Greenhouse/Lever/Workday). New ATSs extend the union.
217
+ *
218
+ * Pre-existing AppCast-specific equivalent: readJobOutcome in
219
+ * recon-replay-jobs.ts. This is the agnostic engine-side version using
220
+ * the flow file's declared pattern.
221
+ */
222
+ function auditFinalSubmitMatch(params) {
223
+ const { ownBackendHostnames, capturesDir, logger } = params;
224
+ if (ownBackendHostnames.length === 0)
225
+ return { auditFailed: true, rejectionReason: null };
226
+ let entries;
227
+ try {
228
+ entries = (0, node_fs_1.readdirSync)(capturesDir);
229
+ }
230
+ catch (err) {
231
+ logger.warn(`end-of-run audit: could not read captures dir ${capturesDir}: ${(0, errors_1.toErrorMessage)(err)}`);
232
+ return { auditFailed: true, rejectionReason: null };
233
+ }
234
+ // Collect the most recent rejection reason in case we don't find a clean
235
+ // 2xx — surfaces "we DID submit but the server rejected" rather than
236
+ // "we never submitted" so the operator knows whether to fix the engine
237
+ // or fix the application content.
238
+ let lastRejectionReason = null;
239
+ for (const f of entries) {
240
+ try {
241
+ const data = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, f), "utf8"));
242
+ if (typeof data.url === "string" &&
243
+ typeof data.status === "number" &&
244
+ data.status >= 200 &&
245
+ data.status < 300) {
246
+ let hostname;
247
+ try {
248
+ hostname = new URL(data.url).hostname;
249
+ }
250
+ catch {
251
+ continue;
252
+ }
253
+ if (!ownBackendHostnames.includes(hostname))
254
+ continue;
255
+ const parsedBody = normalizeResponseBodyForAudit(data);
256
+ const rejection = detectRejectionInResponseBody(parsedBody);
257
+ if (rejection.rejected) {
258
+ lastRejectionReason = rejection.reason;
259
+ continue;
260
+ }
261
+ return { auditFailed: false, rejectionReason: null };
262
+ }
263
+ }
264
+ catch {
265
+ // Ignore unparseable capture files — they're either malformed or
266
+ // a different shape (e.g. resource captures that don't have status/url).
267
+ }
268
+ }
269
+ return { auditFailed: true, rejectionReason: lastRejectionReason };
270
+ }
271
+ function findWizardRestartSignal(params) {
272
+ const { preIdx, restartSignalUrlPatterns } = params;
273
+ if (restartSignalUrlPatterns.length === 0)
274
+ return null;
275
+ const capturesDir = params.capturesDir ?? recon_shared_1.CAPTURES_DIR;
276
+ for (const filename of (0, flow_runner_1.capturesAfterIndex)(preIdx, capturesDir)) {
277
+ let url;
278
+ try {
279
+ const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, filename), "utf8");
280
+ const capture = JSON.parse(raw);
281
+ if (typeof capture.url !== "string")
282
+ continue;
283
+ url = capture.url;
284
+ }
285
+ catch {
286
+ continue;
287
+ }
288
+ for (const pattern of restartSignalUrlPatterns) {
289
+ if (url.includes(pattern))
290
+ return url;
291
+ }
292
+ }
293
+ return null;
294
+ }
295
+ function isStructurallyBlocked(attempts) {
296
+ if (attempts.length === 0)
297
+ return false;
298
+ return attempts.every((a) => a.triedSelectors.length === 0 && a.verifiedBy === null);
299
+ }
300
+ function summarizeReplanFailureKinds(params) {
301
+ const { callsNdjsonPath, callType, tailCount = 10 } = params;
302
+ let ndjsonContent;
303
+ try {
304
+ ndjsonContent = (0, node_fs_1.readFileSync)(callsNdjsonPath, "utf8");
305
+ }
306
+ catch {
307
+ return "";
308
+ }
309
+ const matching = (0, judge_llm_batch_1.filterByCallType)((0, judge_llm_batch_1.parseSamples)(ndjsonContent), callType);
310
+ const failures = matching.filter((s) => s.success === false).slice(-tailCount);
311
+ if (failures.length === 0)
312
+ return "";
313
+ const counts = {};
314
+ for (const s of failures) {
315
+ const kind = s.failureKind ?? "unknown";
316
+ counts[kind] = (counts[kind] ?? 0) + 1;
317
+ }
318
+ const parts = Object.entries(counts)
319
+ .sort((a, b) => b[1] - a[1])
320
+ .map(([kind, n]) => `${n}× ${kind}`);
321
+ return `${failures.length} recent ${callType} failure(s): ${parts.join(", ")}`;
322
+ }
323
+ /**
324
+ * Detect a true replan cycle: the same multi-step instruction sequence
325
+ * proposed REPLAN_CYCLE_THRESHOLD times in a row under page state that
326
+ * hasn't materially advanced. The page-state guard (URL equality + bounded
327
+ * htmlLength delta) is essential — a re-proposal under genuinely different
328
+ * page state is a valid retry, not a cycle. Without the guard we'd block
329
+ * legitimate "the page advanced; the same step now works" recoveries.
330
+ */
331
+ function isReplanCycle(priorReplans, newSteps, currentState) {
332
+ if (priorReplans.length < REPLAN_CYCLE_THRESHOLD)
333
+ return false;
334
+ const newSig = newSteps.map((s) => s.instruction).join("|||");
335
+ let identicalCount = 0;
336
+ for (const prior of priorReplans) {
337
+ const priorSig = prior.replanSteps.map((s) => s.instruction).join("|||");
338
+ if (priorSig !== newSig)
339
+ continue;
340
+ const urlSame = prior.pageState.url === currentState.url;
341
+ const htmlStatic = Math.abs(prior.pageState.htmlLength - currentState.htmlLength) < HTML_STATIC_TOLERANCE;
342
+ if (urlSame && htmlStatic)
343
+ identicalCount++;
344
+ }
345
+ return identicalCount >= REPLAN_CYCLE_THRESHOLD;
346
+ }
347
+ const RECON_FLOW_SCHEMA = v4_1.z.array(schemas_1.RECON_FLOW_STEP_SCHEMA).min(1);
348
+ /**
349
+ * Two on-disk shapes are accepted:
350
+ *
351
+ * 1. Legacy bare array — every existing site flow file uses this.
352
+ * 2. Object form — adds optional `submitEndpointPattern` regex and
353
+ * optional `submittedStateSelectors` array.
354
+ * - `submitEndpointPattern`: the final step's verifier additionally
355
+ * requires at least one same-origin capture in its mutation window
356
+ * whose URL matches the pattern. Without that match `verified=false`,
357
+ * even if the click produced a DOM mutation — which is the only way
358
+ * the self-healing cascade can detect "the click fired tracking but
359
+ * not the real submit XHR."
360
+ * - `submittedStateSelectors`: DOM-level fallback when the submit POST
361
+ * lands outside the per-attempt capture window. SPAs (e.g. AppCast)
362
+ * can swap the form for a thank-you component (`<uapp-universal-
363
+ * submitted-page>`) faster than the network capture pipeline records
364
+ * the POST. If any selector in this list matches via
365
+ * document.querySelector at verifier time, the submit is treated as
366
+ * verified-by-DOM regardless of the network capture.
367
+ *
368
+ * Both forms route through `parseReconFlow` into a shared internal record.
369
+ */
370
+ const RECON_FLOW_FILE_SCHEMA = v4_1.z.union([
371
+ RECON_FLOW_SCHEMA,
372
+ v4_1.z.object({
373
+ steps: RECON_FLOW_SCHEMA,
374
+ submitEndpointPattern: v4_1.z.string().min(1).optional(),
375
+ submittedStateSelectors: v4_1.z.array(v4_1.z.string().min(1)).optional(),
376
+ /**
377
+ * When true, the final-step verifier accepts ONLY a `submitEndpointPattern`
378
+ * network capture as proof of submission — `submittedStateSelectors` DOM
379
+ * matches become a tiebreaker, not a standalone fallback. Use this for
380
+ * SPAs where the success-route component renders optimistically even if
381
+ * the server-side submit was blocked (e.g. by a bot-management WAF), so
382
+ * the DOM marker would otherwise false-positive a failed submission.
383
+ * Default false preserves the lenient pre-existing behavior for sites
384
+ * that genuinely rely on DOM-only verification.
385
+ */
386
+ requireSubmitEndpointMatch: v4_1.z.boolean().optional(),
387
+ /**
388
+ * URL path fragments that indicate a successful submit transition.
389
+ * Surfaced to the Haiku verifySubmit judge as evidence — when the page
390
+ * URL after the click contains any of these, that's one of the strong
391
+ * signals required for verified=true. Examples: ["/applied",
392
+ * "/applyboard/applied", "/confirmation", "/thank-you"]. Site-specific
393
+ * since URL conventions vary across AppCast tenants and ClearCompany
394
+ * tenants.
395
+ */
396
+ successUrlFragments: v4_1.z.array(v4_1.z.string().min(1)).optional(),
397
+ /**
398
+ * Page-title substrings that indicate a successful submit. Same role as
399
+ * successUrlFragments — strong signal for the Haiku verifySubmit judge.
400
+ * Examples: ["Thank you", "Application submitted", "Submitted
401
+ * successfully", "Confirmation"]. English-only is fine; the judge can
402
+ * still reason about non-English variants from the DOM signals.
403
+ */
404
+ successPageTitleHints: v4_1.z.array(v4_1.z.string().min(1)).optional(),
405
+ /**
406
+ * Hostnames considered "the site's own backend." The Haiku
407
+ * verifySubmit judge treats a 2xx POST/PUT/DELETE to one of these
408
+ * hostnames as a corroborating network signal — anything else (e.g.
409
+ * analytics, third-party trackers, CDNs) is ignored. Without this
410
+ * list the judge falls back to the URL alone, which is weaker.
411
+ * Examples: ["apply.appcast.io"], ["careers.clearcompany.com",
412
+ * "<tenant>.clearcompany.com"].
413
+ */
414
+ ownBackendHostnames: v4_1.z.array(v4_1.z.string().min(1)).optional(),
415
+ /**
416
+ * Optional site-specific class-name prefixes that wrap form/error
417
+ * state. The Haiku invalid-fields judge uses these as additional
418
+ * structural evidence beyond framework-conventional patterns
419
+ * (ng-invalid, aria-invalid, data-invalid). Examples for AppCast:
420
+ * ["uapp-", "app-"]. When omitted, the judge falls back to framework
421
+ * conventions alone.
422
+ */
423
+ knownErrorClassPrefixes: v4_1.z.array(v4_1.z.string().min(1)).optional(),
424
+ /**
425
+ * Optional site-specific labels for wizard-exit controls (save-and-exit,
426
+ * cancel, restart) — appended to the engine's built-in
427
+ * WIZARD_EXIT_ACTION_LABELS. An "advance / click Next" step whose resolved
428
+ * action description matches one of these is rejected so the cascade never
429
+ * clicks a destructive control. Only add unambiguously-destructive labels.
430
+ */
431
+ wizardExitButtonLabels: v4_1.z.array(v4_1.z.string().min(1)).optional(),
432
+ /**
433
+ * Optional URL substrings that signal a multi-page wizard RESTART / backward
434
+ * navigation (e.g. `init-apply`, `application_canceled=true`). When any
435
+ * recent capture URL matches after a step, the run aborts with a
436
+ * `wizard-regression` error instead of running later steps against the reset
437
+ * page or replanning against the restarted wizard.
438
+ */
439
+ restartSignalUrlPatterns: v4_1.z.array(v4_1.z.string().min(1)).optional(),
440
+ /**
441
+ * Optional regex matched against the REQUEST BODY of same-window network
442
+ * captures to prove an interior (non-submit) "advance"/"Next" step actually
443
+ * moved the wizard forward — not merely fired some other same-origin POST.
444
+ * Needed for SPAs where advance and non-advance mutations share one endpoint
445
+ * URL (e.g. Talemetry's `/gq`: a real page advance is a `TransitionWorklet`
446
+ * mutation, while `EditQuestionItem` is just a field edit — byte-identical
447
+ * URLs, only the body differs). When set, an advance step's `networkFired`
448
+ * signal is trusted ONLY if a capture body in the step's window matches this
449
+ * pattern. Opt-in: sites that don't set it keep today's behavior (any
450
+ * network/url/dom signal verifies), so no cross-site regression.
451
+ */
452
+ advanceTransitionBodyPattern: v4_1.z.string().min(1).optional(),
453
+ }),
454
+ ]);
455
+ function normalizeFlow(steps) {
456
+ return steps.map((s) => typeof s === "string"
457
+ ? { instruction: s, optional: false, upload: false, origin: "original" }
458
+ : {
459
+ instruction: s.step,
460
+ optional: s.optional,
461
+ upload: s.upload,
462
+ submitStep: s.submitStep,
463
+ payloadField: s.payloadField,
464
+ payloadFieldNone: s.payloadFieldNone,
465
+ origin: "original",
466
+ });
467
+ }
468
+ /**
469
+ * Substitute `${VAR_NAME}` tokens in each step's instruction with
470
+ * `process.env[VAR_NAME]`. Unset variables stay literal so the missing
471
+ * allocation surfaces in Stagehand's act() instruction logs (the literal
472
+ * `${VAR_NAME}` token appears verbatim in the rendered instruction)
473
+ * rather than silently filling with an empty string. Only env keys
474
+ * matching `[A-Z_][A-Z0-9_]*` are substituted — anything else is left
475
+ * as-is, including the JS template literal syntax the flow file may
476
+ * legitimately use elsewhere.
477
+ */
478
+ function substituteFlowEnvVars(steps) {
479
+ const pattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
480
+ return steps.map((s) => ({
481
+ ...s,
482
+ instruction: s.instruction.replace(pattern, (match, name) => {
483
+ const value = process.env[name];
484
+ return value === undefined ? match : value;
485
+ }),
486
+ }));
487
+ }
488
+ /**
489
+ * Inverse of normalizeFlow: maps an internal `NormalizedStep` back to the
490
+ * on-disk union shape. Bare string for the common case (required,
491
+ * non-upload); object with only the truthy flags otherwise. Round-trip is
492
+ * lossless against `RECON_FLOW_SCHEMA` for any value the parser accepted.
493
+ */
494
+ function denormalizeStep(step) {
495
+ const hasSplicerHint = step.payloadField !== undefined || step.payloadFieldNone === true;
496
+ if (!step.optional && !step.upload && !step.submitStep && !hasSplicerHint) {
497
+ return step.instruction;
498
+ }
499
+ const out = {
500
+ step: step.instruction,
501
+ };
502
+ if (step.optional)
503
+ out.optional = true;
504
+ if (step.upload)
505
+ out.upload = true;
506
+ if (step.submitStep)
507
+ out.submitStep = true;
508
+ if (step.payloadField !== undefined)
509
+ out.payloadField = step.payloadField;
510
+ if (step.payloadFieldNone === true)
511
+ out.payloadFieldNone = true;
512
+ return out;
513
+ }
514
+ /**
515
+ * Collapse any consecutive run of structurally-identical denormalized
516
+ * steps into a single entry. Used by `persistReplannedFlow` before write-
517
+ * back so cumulative-replan noise (each cascade-exhausted replan adds the
518
+ * same recovery bridge to the tail; after 5 replans the persisted file
519
+ * carries 5 stacked copies) doesn't pollute the on-disk flow.
520
+ *
521
+ * Comparison is structural via JSON-stringify equality:
522
+ * - Two strings with the same value collapse.
523
+ * - Two objects with the same {step, optional, upload} collapse.
524
+ * - A string and an object with the same instruction do NOT collapse — they
525
+ * are semantically different (bare = required, object = could be optional
526
+ * or upload).
527
+ *
528
+ * Only CONSECUTIVE duplicates collapse. A non-adjacent repeat is preserved
529
+ * since flow authors sometimes intentionally re-fill a field after a
530
+ * downstream interaction (e.g. re-fill First Name after the resume upload
531
+ * triggers an Angular re-render). Adjacent duplicates are almost always
532
+ * accumulation noise from successive replans converging on the same idea.
533
+ */
534
+ function dedupeConsecutiveIdentical(items) {
535
+ if (items.length < 2)
536
+ return [...items];
537
+ const out = [items[0]];
538
+ for (let i = 1; i < items.length; i++) {
539
+ const prev = JSON.stringify(out[out.length - 1]);
540
+ const curr = JSON.stringify(items[i]);
541
+ if (prev !== curr)
542
+ out.push(items[i]);
543
+ }
544
+ return out;
545
+ }
546
+ /**
547
+ * Resume-from-failure filter for replan output. The global replanner should emit
548
+ * only a recovery BRIDGE from the failure point (the original remaining tail is
549
+ * re-appended by the driver), but it sometimes re-emits already-completed steps
550
+ * — re-filling name/email/etc. after a later step fails — which inflates the
551
+ * plan and wastes wall-clock + replan budget re-doing done work. Drop any bridge
552
+ * step whose instruction matches a completed step, EXCEPT a re-emission of the
553
+ * failed step itself (a legitimate no-op bridge the replan prompt allows).
554
+ */
555
+ function filterCompletedFromReplan(newSteps, completedSteps, failedStep) {
556
+ const completed = new Set(completedSteps);
557
+ return newSteps.filter((s) => s.instruction === failedStep || !completed.has(s.instruction));
558
+ }
559
+ /** Whitespace/case-insensitive normalization for comparing step instructions. */
560
+ function normalizeInstruction(instruction) {
561
+ return instruction.replace(/\s+/g, " ").trim().toLowerCase();
562
+ }
563
+ /**
564
+ * Detect a replan that only re-proposes the step that JUST terminally failed —
565
+ * i.e. after {@link filterCompletedFromReplan} the sole surviving bridge step is
566
+ * byte-identical (whitespace/case-normalized) to the failed instruction. The
567
+ * replan prompt allows a no-op re-emission of the failed step, but a bridge that
568
+ * is NOTHING but the failed step is a guaranteed re-fail: resuming re-runs the
569
+ * whole 5-attempt cascade on the exact click that just exhausted it (~1m40s
570
+ * wasted) before the cycle detector — which needs REPLAN_CYCLE_THRESHOLD repeats
571
+ * under a static page — even engages. This catches it on the FIRST occurrence.
572
+ * Pure; returns false whenever the bridge adds any genuinely new step.
573
+ */
574
+ function isReplanReproposingFailedStep(newSteps, failedStep) {
575
+ if (newSteps.length === 0)
576
+ return false;
577
+ const failedNorm = normalizeInstruction(failedStep);
578
+ return newSteps.every((s) => normalizeInstruction(s.instruction) === failedNorm);
579
+ }
580
+ /**
581
+ * Write-back at the end of a successful recon: back up the original file
582
+ * bytes verbatim (so a subtle denormalization bug can never lose the user's
583
+ * hand-authored flow), then write the in-memory plan back out and log a
584
+ * summary of each replan event. No-op when `--no-save-replan` was passed or
585
+ * when no replans fired.
586
+ *
587
+ * Backup path encodes the timestamp + .bak so accumulated backups are easy
588
+ * to sort and prune. Uses synchronous writes — the recon is single-purpose
589
+ * and we want the failure mode "write the bytes, then exit" rather than
590
+ * "exit with the write half-flushed."
591
+ */
592
+ function persistReplannedFlow(params) {
593
+ const { flowFile, finalPlan, replanEvents, logger, originalShape = "array", submitEndpointPattern = null, submittedStateSelectors = [], requireSubmitEndpointMatch = false, successUrlFragments = [], successPageTitleHints = [], ownBackendHostnames = [], knownErrorClassPrefixes = [], } = params;
594
+ // Timestamp format chosen to be filesystem-safe (no colons or dots that
595
+ // break common tooling on macOS/Windows).
596
+ const timestamp = (0, date_fns_1.format)(new Date(), "yyyy-MM-dd'T'HH-mm-ss");
597
+ const backupPath = flowFile.replace(/\.json$/, `.${timestamp}.bak.json`);
598
+ // Read ORIGINAL bytes from disk (not a re-serialization of the parsed
599
+ // structure) so the backup is byte-identical to whatever the user had.
600
+ let originalBytes;
601
+ try {
602
+ originalBytes = (0, node_fs_1.readFileSync)(flowFile);
603
+ }
604
+ catch (err) {
605
+ logger.error(`persistReplannedFlow: failed to read original ${flowFile}: ${(0, errors_1.toErrorMessage)(err)} — skipping write-back (${replanEvents.length} replan event(s) left in memory)`);
606
+ return;
607
+ }
608
+ (0, node_fs_1.writeFileSync)(backupPath, originalBytes);
609
+ // Coerce replan-origin steps to optional before persistence: when a job
610
+ // cascade-exhausts on an employer-specific field, the replan emits a
611
+ // recovery bridge tied to that employer. Persisting it as required would
612
+ // cascade-exhaust every subsequent run on a different employer trying to
613
+ // fill a question that doesn't exist. Optional means the probe-absent-
614
+ // skip path handles employers where the question isn't on the form;
615
+ // cascade still fires for the original employer when the persisted flow
616
+ // is replayed. Required to keep cross-employer sweeps from regressing
617
+ // across runs.
618
+ const denormalizedSteps = finalPlan.map((step) => denormalizeStep(step.origin === "replan" && !step.optional ? { ...step, optional: true } : step));
619
+ // Cumulative-replan dedupe: each cascade-exhausted replan appends a
620
+ // recovery bridge to the tail; after several replans the persisted flow
621
+ // would carry stacked copies of the same bridge. Collapse them so the
622
+ // user reviewing the diff sees only the distinct LLM-discovered steps.
623
+ const dedupedSteps = dedupeConsecutiveIdentical(denormalizedSteps);
624
+ // 2-space indent + trailing newline matches the existing on-disk style
625
+ // (verified against site recon-flow.json files).
626
+ const payload = originalShape === "object"
627
+ ? {
628
+ steps: dedupedSteps,
629
+ ...(submitEndpointPattern !== null ? { submitEndpointPattern } : {}),
630
+ ...(submittedStateSelectors.length > 0 ? { submittedStateSelectors } : {}),
631
+ ...(requireSubmitEndpointMatch ? { requireSubmitEndpointMatch } : {}),
632
+ ...(successUrlFragments.length > 0 ? { successUrlFragments } : {}),
633
+ ...(successPageTitleHints.length > 0 ? { successPageTitleHints } : {}),
634
+ ...(ownBackendHostnames.length > 0 ? { ownBackendHostnames } : {}),
635
+ ...(knownErrorClassPrefixes.length > 0 ? { knownErrorClassPrefixes } : {}),
636
+ }
637
+ : dedupedSteps;
638
+ (0, node_fs_1.writeFileSync)(flowFile, `${JSON.stringify(payload, null, 2)}\n`);
639
+ if (dedupedSteps.length !== denormalizedSteps.length) {
640
+ logger.info(`dedupe collapsed ${denormalizedSteps.length - dedupedSteps.length} consecutive identical step(s) before write-back`);
641
+ }
642
+ // Summary log block — emit each line through the Pino logger so the dev
643
+ // transport renders it nicely and the prod JSON output stays structured.
644
+ // Lines call out the failed instruction string verbatim + the bridge that
645
+ // took its place so a reviewer can diff intent without opening both files.
646
+ logger.info("── flow.json updated ──────────────────────────────────────");
647
+ logger.info(`original backed up: ${backupPath}`);
648
+ logger.info(`replacements (${replanEvents.length}):`);
649
+ for (const ev of replanEvents) {
650
+ logger.info(` - replan #${ev.replanIndex} (${ev.cause}) at step ${ev.indexAtFailure + 1} @ ${ev.timestamp}`);
651
+ logger.info(` failed: ${ev.failedInstruction}`);
652
+ logger.info(` replaced with ${ev.replanSteps.length} step(s):`);
653
+ for (const s of ev.replanSteps) {
654
+ logger.info(` • ${s.instruction}${s.optional ? " (optional)" : ""}${s.upload ? " (upload)" : ""}`);
655
+ }
656
+ }
657
+ logger.info(`run \`diff ${backupPath} ${flowFile}\` to inspect.`);
658
+ logger.info("───────────────────────────────────────────────────────────");
659
+ }
660
+ function renderStepWindow(steps, options = {}) {
661
+ const { head = 0, tail = 10 } = options;
662
+ if (steps.length === 0)
663
+ return "(none)";
664
+ const headSteps = head > 0 ? steps.slice(0, head) : [];
665
+ const tailSteps = tail > 0 ? steps.slice(-tail) : [];
666
+ const elided = steps.length - headSteps.length - tailSteps.length;
667
+ const lines = [];
668
+ for (const [i, s] of headSteps.entries()) {
669
+ lines.push(`${i + 1}. ${s}`);
670
+ }
671
+ if (elided > 0) {
672
+ lines.push(`... (${elided} steps elided for prompt budget) ...`);
673
+ }
674
+ const tailStart = steps.length - tailSteps.length + 1;
675
+ for (const [i, s] of tailSteps.entries()) {
676
+ lines.push(`${tailStart + i}. ${s}`);
677
+ }
678
+ return lines.join("\n");
679
+ }
680
+ /**
681
+ * Detect a "false-premise loop" — the current cascade-exhausted step
682
+ * shares a slug-prefix with at least N prior replans' failed steps,
683
+ * suggesting the flow's element model for THIS widget family doesn't
684
+ * match the actual DOM. Slug derivation mirrors the `currentPhase`
685
+ * pattern at recon-browser.ts:5001 (24-char alphanumeric prefix of
686
+ * normalized instruction). When the threshold is exceeded, callers
687
+ * inject an ELEMENT MODEL CHECK section into the replan prompt so the
688
+ * LLM is nudged to reconsider whether the failed-step's element
689
+ * description matches anything on the page.
690
+ *
691
+ * Research grounding: Reflexion (Shinn et al., 2023) demonstrates +22%
692
+ * improvement on AlfWorld via verbal-reinforcement feedback on prior
693
+ * failures. Our existing replan prompt's PRIOR REPLAN HISTORY section
694
+ * is Reflexion-style; this helper adds a quantified meta-signal when
695
+ * the same element pattern fails N+ times in a row.
696
+ */
697
+ function countSlugPrefixMatches(currentFailedStep, priorReplans) {
698
+ const slugOf = (s) => s
699
+ .replace(/[^a-z0-9]+/gi, "-")
700
+ .toLowerCase()
701
+ .replace(/^-|-$/g, "")
702
+ .slice(0, 24);
703
+ const currentSlug = slugOf(currentFailedStep);
704
+ if (currentSlug.length === 0)
705
+ return 0;
706
+ let matches = 0;
707
+ for (const ev of priorReplans) {
708
+ if (slugOf(ev.failedInstruction) === currentSlug)
709
+ matches++;
710
+ }
711
+ return matches;
712
+ }
713
+ async function readFailureDumpEvidence(failureDumpPath, options) {
714
+ try {
715
+ const dump = JSON.parse((0, node_fs_1.readFileSync)(failureDumpPath, "utf8"));
716
+ const rawBody = dump.bodyOuterHtml;
717
+ const bodyExcerpt = typeof rawBody === "string" ? (0, flow_runner_1.selectBodyExcerpt)(rawBody) : "";
718
+ const client = options?.client ?? null;
719
+ const knownErrorClassPrefixes = options?.knownErrorClassPrefixes ?? [];
720
+ const captureFn = options?.captureFn;
721
+ const page = options?.page;
722
+ // Deterministic-first when the live page is available: probe the LIVE
723
+ // DOM for leaf invalid containers via CSS `:has()`. Falls back to the
724
+ // dump-based Haiku judge only when the live probe returns empty or
725
+ // when no page is in scope (tests). See `probeLeafInvalidContainers`
726
+ // docs for the rationale.
727
+ const leafFields = page ? await (0, flow_runner_1.probeLeafInvalidContainers)(page) : [];
728
+ const [unfocusedList, invalidVerdict, errorVerdict] = await Promise.all([
729
+ (0, flow_runner_1.renderUnfocusedObserve)(dump.unfocusedObserve ?? [], { client, captureFn }),
730
+ leafFields.length > 0
731
+ ? Promise.resolve(null)
732
+ : (0, invalid_fields_1.judgeInvalidFieldsWithLLM)({
733
+ client,
734
+ input: { bodyHtmlExcerpt: bodyExcerpt, knownErrorClassPrefixes },
735
+ captureFn,
736
+ }),
737
+ (0, error_messages_1.judgeErrorMessagesWithLLM)({
738
+ client,
739
+ input: { bodyHtmlExcerpt: bodyExcerpt },
740
+ captureFn,
741
+ }),
742
+ ]);
743
+ const invalidFieldList = leafFields.length > 0
744
+ ? (0, flow_runner_1.renderLeafInvalidFields)(leafFields)
745
+ : (() => {
746
+ const invalidLines = invalidVerdict?.fields.map((f) => {
747
+ const label = f.label ?? "(unlabeled)";
748
+ return `${label} [${f.framework} ${f.markerKind}] ${f.containerXpath}`;
749
+ }) ?? [];
750
+ return invalidLines.map((e, i) => `${i + 1}. ${e}`).join("\n");
751
+ })();
752
+ const errorLines = errorVerdict?.messages.map((m) => {
753
+ const field = m.fieldHint ? `[${m.fieldHint}] ` : "";
754
+ return `${field}${m.severity}: ${m.text}`;
755
+ }) ?? [];
756
+ const errorTextList = errorLines.map((e, i) => `${i + 1}. ${e}`).join("\n");
757
+ // Trailing slice of attempt error messages. The dump's per-attempt
758
+ // errorMessage carries the verifier's structured reason (e.g.
759
+ // `submit-endpoint-not-matched: pattern …`), which currently only the
760
+ // rephrase prompt sees. Surfacing them here gives the replan LLM the
761
+ // same context.
762
+ const recentFailureReasons = (dump.attempts ?? [])
763
+ .map((a) => (typeof a.errorMessage === "string" ? a.errorMessage.trim() : ""))
764
+ .filter((r) => r.length > 0)
765
+ .slice(-5);
766
+ return { bodyExcerpt, unfocusedList, invalidFieldList, errorTextList, recentFailureReasons };
767
+ }
768
+ catch {
769
+ // Swallowed by design: a missing dump must not fail the replan.
770
+ return {
771
+ bodyExcerpt: "",
772
+ unfocusedList: "",
773
+ invalidFieldList: "",
774
+ errorTextList: "",
775
+ recentFailureReasons: [],
776
+ };
777
+ }
778
+ }
779
+ /**
780
+ * Global fallback after a step terminally fails all healing attempts: rewrites
781
+ * only the un-run tail of the flow so already-verified steps are not disturbed.
782
+ * Exported so tests can inject a fake capture sink without a live browser.
783
+ */
784
+ async function replanRemainingFlow(params) {
785
+ const { client, originalFlow, completedSteps, failedStep, remainingSteps, failureDumpPath, page, stagehand, captureFn = call_capture_1.captureLlmCall, recentCaptures = [], ownBackendHostnames = [], knownErrorClassPrefixes: replanKnownErrorClassPrefixes = [], trajectory = [], priorReplans = [], } = params;
786
+ const trajectoryList = trajectory.length > 0
787
+ ? trajectory
788
+ .slice(-5)
789
+ .map((t) => `step ${t.stepIndex + 1} verified via ${t.verifiedBy ?? "(no signal recorded)"}`)
790
+ .join("; ")
791
+ : "";
792
+ const priorReplanList = priorReplans.length > 0
793
+ ? priorReplans
794
+ .map((ev) => `replan #${ev.replanIndex} (failed on: ${ev.failedInstruction})\nproposed:\n${ev.replanSteps
795
+ .map((s, i) => ` ${i + 1}. ${s.instruction}`)
796
+ .join("\n")}`)
797
+ .join("\n\n")
798
+ : "";
799
+ const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: flow_runner_1.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
800
+ const candidateList = await (0, flow_runner_1.renderUnfocusedObserve)(candidates, { client, captureFn });
801
+ const pageTitle = await page.title().catch(() => "");
802
+ // Without raw DOM in the prompt, the LLM only sees stagehand.observe()'s
803
+ // filtered candidate list and hallucinates about surrounding state
804
+ // (auth-wall reset, closed-message interstitial, etc.).
805
+ const { bodyExcerpt, unfocusedList, invalidFieldList, errorTextList, recentFailureReasons } = await readFailureDumpEvidence(failureDumpPath, {
806
+ client,
807
+ knownErrorClassPrefixes: replanKnownErrorClassPrefixes,
808
+ captureFn,
809
+ page,
810
+ });
811
+ const failureReasonList = recentFailureReasons.map((r, i) => `${i + 1}. ${r}`).join("\n");
812
+ const submitFailureList = (0, flow_runner_1.extractSubmitFailureEvidence)(recentCaptures, ownBackendHostnames);
813
+ const gaEventList = (0, flow_runner_1.extractGaEventEvidence)(recentCaptures);
814
+ // False-premise meta-signal: when 2+ prior replans failed on a step
815
+ // sharing the same 24-char slug-prefix as the current failed step,
816
+ // inject an ELEMENT MODEL CHECK section. Reflexion-grounded
817
+ // (verbal-reinforcement on repeated failures); intended to break the
818
+ // degenerate "Click Continue" / "Fill Address" loop the smoke run
819
+ // showed when the cascade got anchored to a non-existent element
820
+ // ("Click the Year spinbutton" steps targeting an HTML5 <input
821
+ // type='date'>).
822
+ const slugPriorMatches = countSlugPrefixMatches(failedStep, priorReplans);
823
+ const elementModelCheck = slugPriorMatches >= 2
824
+ ? `ELEMENT MODEL CHECK — The cascade has now failed ${slugPriorMatches + 1} times on steps matching the same element pattern. When the same element family fails repeatedly, the failed-step's element description may NOT match the actual DOM (e.g. the step asks for "spinbutton" but the page only has <input type="date">; asks for "Select dropdown" but the page has a custom autocomplete; asks for a "Click" target that's a label-only with no clickable child). Inspect PAGE BODY HTML AT FAILURE: if the actual widget in the DOM differs from the failed step's element description, propose a STRUCTURALLY DIFFERENT recovery that targets the actual widget visible on the page (e.g. "Fill in the date input field with today's date" instead of "Click the Month spinbutton") rather than restating the failed step's premise.`
825
+ : "";
826
+ // Structural-block meta-signal: when NO attempt ever resolved a selector for
827
+ // the failed step (every attempt found nothing to act on), the step's target
828
+ // widget/control was never present-and-drivable — rewording the same premise
829
+ // will fail identically. Tell the LLM to change approach or mark impossible,
830
+ // not paraphrase. Read the persisted attempt records from the failure dump.
831
+ const structurallyBlocked = (() => {
832
+ try {
833
+ const dump = JSON.parse((0, node_fs_1.readFileSync)(failureDumpPath, "utf8"));
834
+ const attempts = (dump.attempts ?? []).map((a) => ({
835
+ triedSelectors: a.triedSelectors ?? [],
836
+ verifiedBy: a.verifiedBy ?? null,
837
+ }));
838
+ return isStructurallyBlocked(attempts);
839
+ }
840
+ catch {
841
+ return false;
842
+ }
843
+ })();
844
+ const structuralBlockCheck = structurallyBlocked
845
+ ? `STRUCTURAL BLOCK — Every cascade attempt on the failed step resolved NO element (observe found no candidate; nothing was clicked or filled). The step's target is not present-and-drivable on this page as described. Do NOT merely reword or paraphrase the same premise — it will fail identically. Either (a) propose a STRUCTURALLY DIFFERENT step targeting a control that actually exists in PAGE BODY HTML AT FAILURE / the observed candidates, or (b) if the required control genuinely isn't reachable, return outcome=impossible rather than a cosmetic rewrite.`
846
+ : "";
847
+ const prompt = `You are helping a browser automation agent recover from a failed flow step.
848
+
849
+ ORIGINAL FLOW SUMMARY: ${originalFlow.length} total steps; ${completedSteps.length} executed, ${remainingSteps.length} remaining after the failed step. The completed-tail and remaining-head windows below give you the local context — that's the only flow context replan needs.
850
+
851
+ STEPS ALREADY COMPLETED (these succeeded — do NOT re-emit any of them; the head shows early fills like name/email so you don't repeat them):
852
+ ${renderStepWindow(completedSteps, { head: 8, tail: 10 })}
853
+
854
+ THE STEP THAT JUST FAILED (after exhausting its per-step healing cascade):
855
+ ${failedStep}
856
+
857
+ REMAINING UNEXECUTED STEPS (head of what comes after the failed step; the driver will auto-append the FULL remaining tail after your bridge so do not re-emit these):
858
+ ${renderStepWindow(remainingSteps, { head: 15, tail: 0 })}
859
+
860
+ CURRENT BROWSER STATE:
861
+ URL: ${page.url()}
862
+ Title: ${pageTitle}
863
+
864
+ ${elementModelCheck ? `${elementModelCheck}\n\n` : ""}${structuralBlockCheck ? `${structuralBlockCheck}\n\n` : ""}WHY VERIFICATION FAILED (latest attempt reasons from the cascade — read these carefully, they explain WHY the step is being declared failed):
865
+ ${failureReasonList || "(none)"}
866
+
867
+ PAGE TRANSITION + VALIDATOR TELEMETRY (parsed from Google Analytics Measurement Protocol beacons (POSTs to google-analytics.com/g/collect) captured during the failed step's attempt window — this is the SPA's own telemetry telling you what state it thinks it's in. Watch for: en=view_secondPage / en=view_thirdPage indicating the SPA advanced to a later form page WITHOUT firing Page.frameNavigated (so URL stays the same but questions changed); en=view_thankYouPage indicating the application SUBMITTED SUCCESSFULLY (a stronger success signal than network captures because the /integrated_apply POST is sometimes debounced); epn.validationErrorsCount=N indicating the site's own client validator counts N unfilled required fields. When validationErrorsCount > 0, prefer steps that target unfilled fields over re-clicking Submit/Continue. When view_thankYouPage appears, the application already submitted — do not propose more form-fill steps):
868
+ ${gaEventList || "(none)"}
869
+
870
+ FORM FIELDS CURRENTLY MARKED INVALID (text + class signature for any element whose class matches the framework-agnostic invalid pattern — ng-invalid, mat-form-field-invalid, is-invalid, etc.):
871
+ ${invalidFieldList || "(none)"}
872
+
873
+ VISIBLE ERROR / REQUIRED-FIELD MESSAGES ON THE PAGE (extracted text from error-class containers — error-message, mat-error, field-error, validation-error, invalid-feedback, etc.):
874
+ ${errorTextList || "(none)"}
875
+
876
+ STRUCTURED SERVER-SIDE VALIDATION ERRORS (parsed from captured 4xx responses to the submit endpoint — when populated, the form's submit DID fire and the server rejected it with specific feedback):
877
+ ${submitFailureList || "(none)"}
878
+
879
+ PRIOR REPLAN HISTORY (proposals from previous replans for this same failure; the cascade executed each one and verification still failed at the time it ran. Between replans, the page may have advanced — a step that failed earlier could potentially work now if intervening steps filled missing prerequisites or dismissed blockers. But re-proposing the EXACT same multi-step sequence that already failed is unlikely to produce a different outcome. Prefer structurally different recovery paths. If you re-use a prior step, pair it with new context that addresses why it failed before):
880
+ ${priorReplanList || "(none)"}
881
+
882
+ PRIOR STEP TRAJECTORY (how the last few completed steps verified — url / submitted-state-dom signal pages that visibly transitioned; network / dom signal pages that stayed static. Use this to distinguish "page has been advancing through the flow" from "page has been static and the form is still in front of us"; avoid proposing regression-style steps if the trajectory shows recent transitions):
883
+ ${trajectoryList || "(none)"}
884
+
885
+ ELEMENTS CURRENTLY VISIBLE ON THE PAGE (stagehand.observe with the failed instruction):
886
+ ${candidateList || "(no candidates returned by observe)"}
887
+
888
+ UNFOCUSED OBSERVE (what Stagehand sees on the page without any instruction filter):
889
+ ${unfocusedList || "(none)"}
890
+
891
+ PAGE BODY HTML AT FAILURE (truncated to 8KB — use this to detect interstitials, error messages, auth walls, or unexpected page states that the observe lists miss):
892
+ ${bodyExcerpt || "(missing)"}
893
+
894
+ DIAGNOSTIC DUMP FILE (for reference):
895
+ ${failureDumpPath}
896
+
897
+ Emit ONLY the RECOVERY BRIDGE steps from the failure point back to where the
898
+ original flow's remaining tail can resume. The driver will automatically append
899
+ the original REMAINING UNEXECUTED STEPS (shown above) AFTER your bridge steps,
900
+ so you do NOT need to re-emit them. Your job is just the recovery — the few
901
+ steps needed to get the SPA from its current state into a shape where the
902
+ original tail can run.
903
+
904
+ Concretely:
905
+ - If the page is at an intermediate state the original flow didn't anticipate,
906
+ emit the steps that bridge to where the original flow can pick back up.
907
+ - If the failed step's effect actually happened despite our verifier saying it
908
+ didn't (e.g. SPA-internal navigation that produced no observable signal),
909
+ the bridge may be EMPTY — just re-emit the failed step (or nothing if the
910
+ state already advanced) and the original tail picks up after.
911
+ - Do NOT try to drive the form to completion yourself — the original tail
912
+ covers that. Just unstick the cascade and let it resume.
913
+
914
+ CRITICAL: single-action steps only.
915
+ Each step in your output array MUST invoke exactly ONE DOM action:
916
+ - one \`fill\` on one input, OR
917
+ - one \`click\` on one button/radio/checkbox, OR
918
+ - one \`selectOption\` on one dropdown, OR
919
+ - one observable trigger like "upload the resume PDF".
920
+
921
+ The underlying agent (Stagehand \`act()\`) can only execute one action per step.
922
+ Multi-action steps silently drop all but one action and corrupt downstream form state.
923
+
924
+ WRONG (multi-action — DO NOT emit steps like these):
925
+ "Fill in First Name 'Reginald', Last Name 'Reconaldo', Email '...'"
926
+ "If Street is visible fill Street; if City is visible fill City; then click Continue"
927
+ "Fill the signature field with 'Name'; check the I agree box; click Submit"
928
+
929
+ RIGHT (single-action — emit steps like these):
930
+ "Fill in the First Name field with 'Reginald'"
931
+ "Fill in the Last Name field with 'Reconaldo'"
932
+ "Fill in the Email field with '...'"
933
+ "If a Street Address field is visible, fill it with '123 Test Lane'"
934
+ "If a City field is visible, fill it with 'Austin'"
935
+ "Click the Continue button"
936
+ "Fill the signature field with 'Name'"
937
+ "Check the I agree checkbox"
938
+ "Click the Submit button"
939
+
940
+ A step CAN combine ONE conditional + ONE action ("If X is visible, do Y") — that
941
+ counts as single-action because the conditional only gates whether the action
942
+ runs. But NEVER combine multiple actions even when they're each conditional.
943
+
944
+ Constraints:
945
+ - Do NOT include the already-completed steps in your output.
946
+ - Do NOT include the original REMAINING UNEXECUTED STEPS in your output — they will be appended automatically after your bridge.
947
+ - Emit ONLY the recovery bridge steps that get the SPA from its current state to where the original tail can resume.
948
+ - If you can recover the flow, return outcome="replan" with the steps array (can be just 1-2 steps if that's all that's needed).
949
+ - If the user's intent is unreachable from this page state, return outcome="impossible" with a brief reason.
950
+ - Maximum ${schemas_1.REPLAN_MAX_STEPS} bridge steps.
951
+ - Each step is a single DOM action (see CRITICAL section above).
952
+
953
+ OPTIONAL STEPS:
954
+ Each step entry can be a bare string (required step — cascade fails if the
955
+ target is missing) OR an object \`{step: "...", optional: true}\` (cascade
956
+ skips cleanly if Stagehand observes no candidates).
957
+
958
+ Use optional ONLY when the action is genuinely conditional on the page having
959
+ a specific element. Examples:
960
+ - "If a 'Currently employed here' checkbox is visible, check it" → emit as
961
+ \`{step: "Check the 'Currently employed here' checkbox", optional: true}\`
962
+ - "If an 'Add Experience' button is visible, click it" → emit as
963
+ \`{step: "Click the 'Add Experience' button", optional: true}\`
964
+
965
+ Do NOT mark required actions optional (form fills the user needs filled, the
966
+ Continue/Submit button at the end of a section, etc.) — that would silently
967
+ skip them when the cascade can't see them, leaving the form half-filled.
968
+
969
+ UPLOAD STEPS:
970
+ A step that uploads a file to a file input MUST be emitted as
971
+ \`{step: "...", upload: true}\` so the cascade routes it to the file-upload
972
+ primitive (which handles widgets that hide the real <input type=file> behind
973
+ a styled button Stagehand can't click).
974
+
975
+ Do NOT mark non-upload actions as upload — even if their description mentions
976
+ "upload" or "resume". Examples:
977
+ - "Upload the test resume PDF when the upload screen appears" → emit as
978
+ \`{step: "Upload the test resume PDF", upload: true}\`
979
+ - "Click Continue past the resume upload screen" → emit as the bare string
980
+ "Click Continue past the resume upload screen" (NOT upload — it's a click).
981
+ - "Click the Remove button to delete the previous resume" → bare string
982
+ (NOT upload — it's a click).
983
+
984
+ A step CAN be both upload and optional (object form with both fields set),
985
+ but that's rare — most upload steps are required.`;
986
+ const model = (0, flow_runner_1.anthropicModelName)();
987
+ const t0 = performance.now();
988
+ try {
989
+ const response = await client.messages.parse({
990
+ model,
991
+ max_tokens: 2000,
992
+ messages: [{ role: "user", content: prompt }],
993
+ output_config: {
994
+ format: (0, zod_1.zodOutputFormat)(schemas_1.REPLAN_RESPONSE_SCHEMA),
995
+ },
996
+ });
997
+ const latencyMs = performance.now() - t0;
998
+ // Structured output: SDK throws on JSON or schema-validation failure, so
999
+ // reaching this point means parsed_output is the validated object. The
1000
+ // typings keep T | null in the signature for the "no format supplied"
1001
+ // branch — guard so the discriminated-union narrows cleanly below.
1002
+ const parsed = response.parsed_output;
1003
+ if (parsed === null) {
1004
+ throw new Error("structured-output enabled but parsed_output is null");
1005
+ }
1006
+ const textBlock = response.content.find((b) => b.type === "text");
1007
+ const rawText = textBlock?.type === "text" ? textBlock.text : "";
1008
+ await captureFn({
1009
+ callId: (0, node_crypto_1.randomUUID)(),
1010
+ callType: call_types_1.CALL_TYPE_RECON_REPLAN,
1011
+ model,
1012
+ systemPrompt: null,
1013
+ userContent: prompt,
1014
+ responseContent: rawText,
1015
+ parsedOk: true,
1016
+ inputTokens: response.usage?.input_tokens ?? null,
1017
+ outputTokens: response.usage?.output_tokens ?? null,
1018
+ latencyMs,
1019
+ success: true,
1020
+ errorMessage: null,
1021
+ failureKind: null,
1022
+ });
1023
+ if (parsed.outcome === "replan")
1024
+ return normalizeFlow(parsed.steps);
1025
+ return null;
1026
+ }
1027
+ catch (err) {
1028
+ const message = err instanceof Error ? err.message : String(err);
1029
+ (0, flow_runner_1.logBillingErrorIfPresent)(err);
1030
+ await captureFn({
1031
+ callId: (0, node_crypto_1.randomUUID)(),
1032
+ callType: call_types_1.CALL_TYPE_RECON_REPLAN,
1033
+ model,
1034
+ systemPrompt: null,
1035
+ userContent: prompt,
1036
+ responseContent: null,
1037
+ parsedOk: false,
1038
+ inputTokens: null,
1039
+ outputTokens: null,
1040
+ latencyMs: performance.now() - t0,
1041
+ success: false,
1042
+ errorMessage: message,
1043
+ failureKind: (0, call_capture_1.classifyLlmCallFailure)(err),
1044
+ });
1045
+ return null;
1046
+ }
1047
+ }
1048
+ function dumpReplanRecord(params) {
1049
+ (0, node_fs_1.mkdirSync)(recon_shared_1.STEP_FAILURES_DIR, { recursive: true });
1050
+ const idx = String(params.stepIndex).padStart(3, "0");
1051
+ const filename = `${idx}-${params.phase}.replan.json`;
1052
+ const target = (0, node_path_1.join)(recon_shared_1.STEP_FAILURES_DIR, filename);
1053
+ (0, node_fs_1.writeFileSync)(target, JSON.stringify({
1054
+ timestamp: new Date().toISOString(),
1055
+ stepIndex: params.stepIndex,
1056
+ phase: params.phase,
1057
+ replanIndex: params.replanIndex,
1058
+ completedSteps: params.completedSteps,
1059
+ originalRemaining: params.originalRemaining,
1060
+ newRemaining: params.newRemaining,
1061
+ }, null, 2));
1062
+ return target;
1063
+ }
1064
+ function dumpStepFailure(params) {
1065
+ (0, node_fs_1.mkdirSync)(recon_shared_1.STEP_FAILURES_DIR, { recursive: true });
1066
+ const idx = String(params.stepIndex).padStart(3, "0");
1067
+ const filename = `${idx}-${params.phase}.json`;
1068
+ const bundle = {
1069
+ timestamp: new Date().toISOString(),
1070
+ stepIndex: params.stepIndex,
1071
+ phase: params.phase,
1072
+ originalStep: params.originalStep,
1073
+ pageUrl: params.pageUrl,
1074
+ pageTitle: params.pageTitle,
1075
+ attempts: params.attempts,
1076
+ finalObserve: params.finalObserve,
1077
+ unfocusedObserve: params.unfocusedObserve,
1078
+ bodyOuterHtml: params.bodyOuterHtml,
1079
+ recentCaptures: params.recentCaptures.slice(-5),
1080
+ };
1081
+ const target = (0, node_path_1.join)(recon_shared_1.STEP_FAILURES_DIR, filename);
1082
+ (0, node_fs_1.writeFileSync)(target, JSON.stringify(bundle, null, 2));
1083
+ return target;
1084
+ }
1085
+ const DEFAULT_RESUME_FIXTURE_PATH = "src/testing/fixtures/resume.pdf";
1086
+ function parseCli() {
1087
+ const args = process.argv.slice(2);
1088
+ let url = "";
1089
+ let rawFlow = null;
1090
+ let flowFile = null;
1091
+ let provider;
1092
+ // Precedence: --resume-fixture flag > RESUME_FIXTURE_PATH env > default path.
1093
+ let resumeFixturePath = process.env.RESUME_FIXTURE_PATH || DEFAULT_RESUME_FIXTURE_PATH;
1094
+ let saveReplan = true;
1095
+ let advancedStealth = false;
1096
+ let dumpDomBeforeStep = null;
1097
+ let allocateEmailEnvVar = null;
1098
+ for (let i = 0; i < args.length; i++) {
1099
+ if (args[i] === "--url" && args[i + 1]) {
1100
+ url = args[++i];
1101
+ }
1102
+ else if (args[i] === "--flow" && args[i + 1]) {
1103
+ rawFlow = JSON.parse(args[++i]);
1104
+ }
1105
+ else if (args[i] === "--flow-file" && args[i + 1]) {
1106
+ flowFile = (0, node_path_1.resolve)(args[++i]);
1107
+ }
1108
+ else if (args[i] === "--provider" && args[i + 1]) {
1109
+ const raw = args[++i].toLowerCase();
1110
+ if (raw !== "browserbase" && raw !== "steel") {
1111
+ logger.error(`--provider must be "browserbase" or "steel" (got ${JSON.stringify(raw)})`);
1112
+ process.exit(1);
1113
+ }
1114
+ provider = raw;
1115
+ }
1116
+ else if (args[i] === "--resume-fixture" && args[i + 1]) {
1117
+ resumeFixturePath = args[++i];
1118
+ }
1119
+ else if (args[i] === "--no-save-replan") {
1120
+ saveReplan = false;
1121
+ }
1122
+ else if (args[i] === "--advanced-stealth") {
1123
+ advancedStealth = true;
1124
+ }
1125
+ else if (args[i] === "--dump-dom-before-step" && args[i + 1]) {
1126
+ const n = Number.parseInt(args[++i], 10);
1127
+ if (!Number.isInteger(n) || n < 1) {
1128
+ logger.error(`--dump-dom-before-step must be a positive integer (got ${JSON.stringify(args[i])})`);
1129
+ process.exit(1);
1130
+ }
1131
+ dumpDomBeforeStep = n;
1132
+ }
1133
+ else if (args[i] === "--allocate-email" && args[i + 1]) {
1134
+ const name = args[++i];
1135
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) {
1136
+ logger.error(`--allocate-email value must be an UPPERCASE_SNAKE_CASE env var name (got ${JSON.stringify(name)})`);
1137
+ process.exit(1);
1138
+ }
1139
+ allocateEmailEnvVar = name;
1140
+ }
1141
+ }
1142
+ if (!url) {
1143
+ logger.error('usage: recon-browser.ts --url <url> [--flow \'["step1","step2"]\'] [--flow-file <path>] [--provider browserbase|steel] [--resume-fixture <path>] [--no-save-replan] [--advanced-stealth] [--dump-dom-before-step <N>] [--allocate-email <ENV_VAR_NAME>]');
1144
+ process.exit(1);
1145
+ }
1146
+ if (flowFile) {
1147
+ if (rawFlow !== null) {
1148
+ logger.warn("recon-browser: --flow-file takes precedence over --flow");
1149
+ }
1150
+ try {
1151
+ rawFlow = JSON.parse((0, node_fs_1.readFileSync)(flowFile, "utf8"));
1152
+ }
1153
+ catch (err) {
1154
+ logger.error(`failed to read --flow-file ${flowFile}: ${(0, errors_1.toErrorMessage)(err)}`);
1155
+ process.exit(1);
1156
+ }
1157
+ }
1158
+ if (rawFlow === null) {
1159
+ return {
1160
+ url,
1161
+ flow: [],
1162
+ flowFile,
1163
+ provider,
1164
+ resumeFixturePath,
1165
+ saveReplan,
1166
+ advancedStealth,
1167
+ dumpDomBeforeStep,
1168
+ allocateEmailEnvVar,
1169
+ submitEndpointPattern: null,
1170
+ submittedStateSelectors: [],
1171
+ requireSubmitEndpointMatch: false,
1172
+ advanceTransitionBodyPattern: null,
1173
+ successUrlFragments: [],
1174
+ successPageTitleHints: [],
1175
+ ownBackendHostnames: [],
1176
+ knownErrorClassPrefixes: [],
1177
+ wizardExitButtonLabels: [],
1178
+ restartSignalUrlPatterns: [],
1179
+ originalShape: "array",
1180
+ };
1181
+ }
1182
+ const parsed = RECON_FLOW_FILE_SCHEMA.safeParse(rawFlow);
1183
+ if (!parsed.success) {
1184
+ logger.error(`flow file/arg failed schema validation: ${parsed.error.message}`);
1185
+ process.exit(1);
1186
+ }
1187
+ const stepsRaw = Array.isArray(parsed.data) ? parsed.data : parsed.data.steps;
1188
+ const submitEndpointPattern = Array.isArray(parsed.data)
1189
+ ? null
1190
+ : (parsed.data.submitEndpointPattern ?? null);
1191
+ const submittedStateSelectors = Array.isArray(parsed.data)
1192
+ ? []
1193
+ : (parsed.data.submittedStateSelectors ?? []);
1194
+ const requireSubmitEndpointMatch = Array.isArray(parsed.data)
1195
+ ? false
1196
+ : (parsed.data.requireSubmitEndpointMatch ?? false);
1197
+ const advanceTransitionBodyPattern = Array.isArray(parsed.data)
1198
+ ? null
1199
+ : (parsed.data.advanceTransitionBodyPattern ?? null);
1200
+ const successUrlFragments = Array.isArray(parsed.data)
1201
+ ? []
1202
+ : (parsed.data.successUrlFragments ?? []);
1203
+ const successPageTitleHints = Array.isArray(parsed.data)
1204
+ ? []
1205
+ : (parsed.data.successPageTitleHints ?? []);
1206
+ const ownBackendHostnames = Array.isArray(parsed.data)
1207
+ ? []
1208
+ : (parsed.data.ownBackendHostnames ?? []);
1209
+ const knownErrorClassPrefixes = Array.isArray(parsed.data)
1210
+ ? []
1211
+ : (parsed.data.knownErrorClassPrefixes ?? []);
1212
+ const wizardExitButtonLabels = Array.isArray(parsed.data)
1213
+ ? []
1214
+ : (parsed.data.wizardExitButtonLabels ?? []);
1215
+ const restartSignalUrlPatterns = Array.isArray(parsed.data)
1216
+ ? []
1217
+ : (parsed.data.restartSignalUrlPatterns ?? []);
1218
+ const isArrayShape = Array.isArray(parsed.data);
1219
+ // Validate regex compiles eagerly so a malformed pattern fails the run
1220
+ // at startup, not deep in a per-step verifier.
1221
+ if (submitEndpointPattern !== null) {
1222
+ try {
1223
+ new RegExp(submitEndpointPattern);
1224
+ }
1225
+ catch (err) {
1226
+ logger.error(`flow file: submitEndpointPattern is not a valid regex: ${(0, errors_1.toErrorMessage)(err)}`);
1227
+ process.exit(1);
1228
+ }
1229
+ }
1230
+ return {
1231
+ url,
1232
+ flow: normalizeFlow(stepsRaw),
1233
+ flowFile,
1234
+ provider,
1235
+ resumeFixturePath,
1236
+ saveReplan,
1237
+ advancedStealth,
1238
+ dumpDomBeforeStep,
1239
+ allocateEmailEnvVar,
1240
+ submitEndpointPattern,
1241
+ submittedStateSelectors,
1242
+ requireSubmitEndpointMatch,
1243
+ advanceTransitionBodyPattern,
1244
+ successUrlFragments,
1245
+ successPageTitleHints,
1246
+ ownBackendHostnames,
1247
+ knownErrorClassPrefixes,
1248
+ wizardExitButtonLabels,
1249
+ restartSignalUrlPatterns,
1250
+ originalShape: isArrayShape ? "array" : "object",
1251
+ };
1252
+ }
1253
+ /**
1254
+ * Loads the resume fixture from disk at startup so the upload primitive
1255
+ * doesn't re-read the file from disk for every recon step. Returns null
1256
+ * when the file doesn't exist — the primitive then falls through to the
1257
+ * regular cascade and the recon continues unchanged (so flows that don't
1258
+ * involve resume uploads aren't affected by a missing fixture).
1259
+ */
1260
+ function loadResumeFixture(path) {
1261
+ try {
1262
+ const buffer = (0, node_fs_1.readFileSync)(path);
1263
+ const name = path.split("/").pop() ?? "resume.pdf";
1264
+ // Conservative: every site we've targeted accepts PDF; if we ever ship a
1265
+ // .docx fixture we'd extend this map. Default keeps the primitive safe.
1266
+ const mimeType = name.toLowerCase().endsWith(".pdf")
1267
+ ? "application/pdf"
1268
+ : "application/octet-stream";
1269
+ return { buffer, name, mimeType };
1270
+ }
1271
+ catch (err) {
1272
+ logger.warn(`resume fixture not loaded from ${path}: ${(0, errors_1.toErrorMessage)(err)} — upload primitive will fall through`);
1273
+ return null;
1274
+ }
1275
+ }
1276
+ async function main() {
1277
+ const { url, flow: rawFlow, flowFile, provider, resumeFixturePath, saveReplan, advancedStealth, dumpDomBeforeStep, allocateEmailEnvVar, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, restartSignalUrlPatterns, originalShape, } = parseCli();
1278
+ // Allocate a fresh testmail.app inbox + bind it to the requested env var
1279
+ // BEFORE substituting placeholders in the flow. Subsequent ${ENV_VAR}
1280
+ // tokens anywhere in the flow's instruction strings resolve to the
1281
+ // freshly-allocated address.
1282
+ if (allocateEmailEnvVar) {
1283
+ const inbox = (0, client_1.allocateTestmailInbox)();
1284
+ process.env[allocateEmailEnvVar] = inbox.address;
1285
+ logger.info(`bound allocated testmail address to env var ${allocateEmailEnvVar}=${inbox.address}`);
1286
+ }
1287
+ const flow = substituteFlowEnvVars(rawFlow);
1288
+ (0, node_fs_1.mkdirSync)(recon_shared_1.CAPTURES_DIR, { recursive: true });
1289
+ const resumeFixture = loadResumeFixture(resumeFixturePath);
1290
+ // Per-URL partition under the flow file's site directory. Without a flow
1291
+ // file (inline --flow mode), telemetry has no durable home and is dropped
1292
+ // — captureFn becomes a no-op so dev one-offs don't crash and don't
1293
+ // pollute the global sink.
1294
+ const siteTelemetryDir = (0, telemetry_paths_1.resolveSiteTelemetryDir)(flowFile);
1295
+ // Capture the run-start timestamp once so the calls.ndjson and url.txt
1296
+ // sidecar resolve to the same directory.
1297
+ const runTimestampMs = Date.now();
1298
+ const callsNdjsonPath = siteTelemetryDir !== null ? (0, telemetry_paths_1.resolveRunCallsPath)(siteTelemetryDir, runTimestampMs, url) : null;
1299
+ if (siteTelemetryDir !== null && callsNdjsonPath !== null) {
1300
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(callsNdjsonPath), { recursive: true });
1301
+ (0, node_fs_1.writeFileSync)((0, telemetry_paths_1.resolveRunUrlPath)(siteTelemetryDir, runTimestampMs, url), `${url}\n`);
1302
+ logger.info(`telemetry: per-URL partition at ${callsNdjsonPath}`);
1303
+ }
1304
+ else {
1305
+ logger.info("telemetry: no flow file path — call capture disabled for this run");
1306
+ }
1307
+ const captureFn = callsNdjsonPath !== null
1308
+ ? (input) => (0, call_capture_1.captureLlmCall)(input, { sinkPath: callsNdjsonPath })
1309
+ : async () => { };
1310
+ logger.info(`recon-browser: target=${url} flow_steps=${flow.length} provider=${provider ?? "(config-default)"} advancedStealth=${advancedStealth} resume_fixture=${resumeFixture ? `${resumeFixturePath} (${resumeFixture.buffer.length}b)` : "(missing)"} out=${recon_shared_1.CAPTURES_DIR}`);
1311
+ const session = await (0, session_1.createBrowserSession)({ provider, advancedStealth });
1312
+ // `counter` indexes captures on disk (filenames must stay unique).
1313
+ // `signalCounter` drives the verifier — only non-GET methods increment
1314
+ // it so coincident polling/page-load GETs don't falsely "verify" a
1315
+ // click that produced no real effect. See the onFinished comment in
1316
+ // wireNetworkCapture for the rationale.
1317
+ const counter = { n: 0 };
1318
+ const signalCounter = { n: 0 };
1319
+ const recentCaptures = [];
1320
+ // Parallel tracker of recent non-GET captures' method + status. Used by
1321
+ // the Tier 1 trailing-optional-step grace: a verification failure on an
1322
+ // optional trailing step is treated as a benign no-op when a recent
1323
+ // mutation returned 2xx (i.e. the SPA's "real work" already completed
1324
+ // server-side and the trailing step is a redundant tail that the flow
1325
+ // file may have included for sites where it's actually needed). GETs are
1326
+ // filtered at the push site so the window isn't washed out by SPA chunk
1327
+ // loads — see the comment in wireNetworkCapture's onFinished.
1328
+ const recentCaptureMeta = [];
1329
+ // Hoisted out of the try block so the finally can run the replan
1330
+ // write-back even when the cascade throws — replan-discovered steps
1331
+ // accumulated up to the failure point should survive a cascade-exhausted
1332
+ // exit so the user can review them and the next run starts where this
1333
+ // one left off. The "only on success" gate before was the reason most
1334
+ // recon discoveries got thrown away on failed runs.
1335
+ const plan = [];
1336
+ const replanEvents = [];
1337
+ try {
1338
+ const stagehand = session.stagehand;
1339
+ const page = await stagehand.context.awaitActivePage();
1340
+ // Phase label is mutated between flow steps so the single CDP listener
1341
+ // always tags captures with the currently active phase.
1342
+ let currentPhase = "home";
1343
+ const stopCapture = wireNetworkCapture(page, counter, signalCounter, recentCaptures, recentCaptureMeta, () => currentPhase, () => {
1344
+ // Live read so SPA history navigation between captures stays accurate.
1345
+ // Fallback to the initial url on parse failure (e.g. about:blank early
1346
+ // in the goto cycle) so we don't accidentally mark a capture as
1347
+ // cross-origin and miss the user-action signal.
1348
+ try {
1349
+ return new URL(page.url()).origin;
1350
+ }
1351
+ catch {
1352
+ return new URL(url).origin;
1353
+ }
1354
+ });
1355
+ logger.info(`navigating to ${url}`);
1356
+ await page.goto(url, { waitUntil: "networkidle", timeoutMs: flow_runner_1.GOTO_TIMEOUT_MS });
1357
+ const SPA_READINESS_TIMEOUT_MS = 15_000;
1358
+ const SPA_READINESS_POLL_MS = 500;
1359
+ const SPA_MIN_BODY_LENGTH = 5_000;
1360
+ const spaDeadline = Date.now() + SPA_READINESS_TIMEOUT_MS;
1361
+ let bodyLength = await page
1362
+ .evaluate("document.body ? document.body.outerHTML.length : 0")
1363
+ .catch(() => 0);
1364
+ if (typeof bodyLength === "number" && bodyLength < SPA_MIN_BODY_LENGTH) {
1365
+ logger.info(`spa readiness: body ${bodyLength} chars < ${SPA_MIN_BODY_LENGTH} threshold — waiting for SPA to render`);
1366
+ while (Date.now() < spaDeadline) {
1367
+ await new Promise((r) => setTimeout(r, SPA_READINESS_POLL_MS));
1368
+ bodyLength = await page
1369
+ .evaluate("document.body ? document.body.outerHTML.length : 0")
1370
+ .catch(() => 0);
1371
+ if (typeof bodyLength === "number" && bodyLength >= SPA_MIN_BODY_LENGTH) {
1372
+ logger.info(`spa readiness: body grew to ${bodyLength} chars — SPA rendered`);
1373
+ break;
1374
+ }
1375
+ }
1376
+ if (typeof bodyLength === "number" && bodyLength < SPA_MIN_BODY_LENGTH) {
1377
+ logger.warn(`spa readiness: body still ${bodyLength} chars after ${SPA_READINESS_TIMEOUT_MS}ms — proceeding with possibly incomplete page`);
1378
+ }
1379
+ }
1380
+ const anthropic = (0, anthropic_client_1.buildAnthropicClient)();
1381
+ if (!anthropic) {
1382
+ logger.warn("bedrock-only deployment: attempt-4 llm rephrase and global replan will be skipped on step failures");
1383
+ }
1384
+ plan.push(...flow);
1385
+ const completedSteps = [];
1386
+ const trajectory = [];
1387
+ let probeReplansUsed = 0;
1388
+ let cascadeReplansUsed = 0;
1389
+ const STUCK_SKIP_THRESHOLD = 5;
1390
+ let consecutiveStaleSkips = 0;
1391
+ let lastSuccessNetworkCount = signalCounter.n;
1392
+ let lastSuccessUrl = page.url();
1393
+ // Track the page origin so a cross-origin navigation mid-flow (e.g. the
1394
+ // Apply click taking careers.hcahealthcare.com → apply.talemetry.com) can
1395
+ // re-gate on SPA hydration. The initial goto's readiness gate only covers
1396
+ // the landing page; the wizard app boots on a DIFFERENT origin with no gate,
1397
+ // so its first steps would otherwise probe an un-hydrated shell and skip.
1398
+ const originOf = (u) => {
1399
+ try {
1400
+ return new URL(u).origin;
1401
+ }
1402
+ catch {
1403
+ return "";
1404
+ }
1405
+ };
1406
+ let lastOrigin = originOf(page.url());
1407
+ for (let i = 0; i < plan.length; i++) {
1408
+ const step = plan[i];
1409
+ currentPhase =
1410
+ step.instruction
1411
+ .replace(/[^a-z0-9]+/gi, "-")
1412
+ .toLowerCase()
1413
+ .replace(/^-|-$/g, "")
1414
+ .slice(0, 24) || `step-${i}`;
1415
+ logger.info(`step ${i + 1}/${plan.length} [${currentPhase}]${step.optional ? " (optional)" : ""}: ${step.instruction}`);
1416
+ // Re-gate on SPA hydration when the origin changed since the last step —
1417
+ // the wizard SPA (e.g. apply.talemetry.com after the Apply click) boots on
1418
+ // a new origin the initial-goto readiness gate never covered, so wait for
1419
+ // its body to render before probing rather than skipping a shell page.
1420
+ const currentOrigin = originOf(page.url());
1421
+ if (currentOrigin !== "" && currentOrigin !== lastOrigin) {
1422
+ logger.info(`origin changed ${lastOrigin || "(none)"} → ${currentOrigin}; re-gating on SPA hydration`);
1423
+ await (0, flow_runner_1.waitForSpaReady)(page, logger);
1424
+ lastOrigin = currentOrigin;
1425
+ }
1426
+ // Debug: dump the full DOM right before this step's cascade runs. Lets
1427
+ // a triager see the page state exactly as the cascade sees it, without
1428
+ // re-running. One-shot per recon run via --dump-dom-before-step.
1429
+ if (dumpDomBeforeStep !== null && i + 1 === dumpDomBeforeStep) {
1430
+ try {
1431
+ const html = await page.evaluate("document.documentElement ? document.documentElement.outerHTML : ''");
1432
+ if (typeof html === "string" && html.length > 0) {
1433
+ const dumpPath = (0, node_path_1.join)(recon_shared_1.CAPTURES_DIR, `..`, `dom-dump-step-${i + 1}.html`);
1434
+ (0, node_fs_1.writeFileSync)(dumpPath, html);
1435
+ logger.info(`step ${i + 1}: wrote DOM dump (${html.length} bytes) to ${dumpPath}`);
1436
+ }
1437
+ else {
1438
+ logger.warn(`step ${i + 1}: DOM dump returned empty content; skipping write`);
1439
+ }
1440
+ }
1441
+ catch (err) {
1442
+ logger.warn(`step ${i + 1}: DOM dump failed: ${(0, errors_1.toErrorMessage)(err)}`);
1443
+ }
1444
+ }
1445
+ // Baseline for wizard-restart detection: capture the highest capture
1446
+ // INDEX before the step so findWizardRestartSignal scans only URLs that
1447
+ // landed during THIS step's processing (eviction-proof disk scan).
1448
+ const preCaptureIdxBeforeStep = (0, flow_runner_1.latestCaptureIndex)(recentCaptures);
1449
+ try {
1450
+ const stepOutcome = await (0, flow_runner_1.executeStepWithHealing)({
1451
+ stagehand,
1452
+ page,
1453
+ step: step.instruction,
1454
+ optional: step.optional,
1455
+ upload: step.upload,
1456
+ submitStep: step.submitStep === true,
1457
+ stepIndex: i,
1458
+ phase: currentPhase,
1459
+ signalCounter,
1460
+ recentCaptures,
1461
+ recentCaptureMeta,
1462
+ anthropic,
1463
+ logger,
1464
+ resumeFixture,
1465
+ isFinalStep: i === plan.length - 1,
1466
+ submitEndpointPattern,
1467
+ submittedStateSelectors,
1468
+ requireSubmitEndpointMatch,
1469
+ advanceTransitionBodyPattern,
1470
+ successUrlFragments,
1471
+ successPageTitleHints,
1472
+ ownBackendHostnames,
1473
+ knownErrorClassPrefixes,
1474
+ wizardExitButtonLabels,
1475
+ trajectory,
1476
+ captureFn,
1477
+ onStepFailure: dumpStepFailure,
1478
+ });
1479
+ // Wizard-restart detection: if a configured restart-signal URL (e.g.
1480
+ // Talemetry's `init-apply?...&application_canceled=true`) landed during
1481
+ // this step, the multi-page wizard reset to page 1. Remaining steps now
1482
+ // target a reset page and replanning against the restarted wizard is
1483
+ // futile — abort with a diagnostic instead of silently cycling.
1484
+ const restartUrl = findWizardRestartSignal({
1485
+ preIdx: preCaptureIdxBeforeStep,
1486
+ restartSignalUrlPatterns,
1487
+ });
1488
+ if (restartUrl !== null) {
1489
+ throw new errors_2.StepVerificationError(`step ${i + 1} (${step.instruction.slice(0, 60)}) triggered a wizard restart (${restartUrl.slice(0, 120)}) — the application reset to the first page; aborting`, "wizard-regression");
1490
+ }
1491
+ if (stepOutcome === "skipped") {
1492
+ const pageStagnant = signalCounter.n === lastSuccessNetworkCount && page.url() === lastSuccessUrl;
1493
+ if (pageStagnant) {
1494
+ consecutiveStaleSkips++;
1495
+ }
1496
+ if (consecutiveStaleSkips >= STUCK_SKIP_THRESHOLD) {
1497
+ logger.warn(`stuck detection: ${consecutiveStaleSkips} consecutive optional steps skipped with no page advancement (url=${lastSuccessUrl}, networkCount=${lastSuccessNetworkCount}) — treating as probe-absent failure to trigger replan`);
1498
+ consecutiveStaleSkips = 0;
1499
+ throw new errors_2.StepVerificationError(`step ${i + 1} (${step.instruction.slice(0, 60)}) stuck: ${STUCK_SKIP_THRESHOLD}+ consecutive optional skips with stagnant page`, "probe-absent");
1500
+ }
1501
+ }
1502
+ else {
1503
+ consecutiveStaleSkips = 0;
1504
+ lastSuccessNetworkCount = signalCounter.n;
1505
+ lastSuccessUrl = page.url();
1506
+ }
1507
+ completedSteps.push(step.instruction);
1508
+ }
1509
+ catch (err) {
1510
+ if (!(err instanceof errors_2.StepVerificationError))
1511
+ throw err;
1512
+ // Unrecoverable backend-error short-circuit. When the cascade
1513
+ // detected a same-window 5xx on the submit endpoint, no amount of
1514
+ // replan or rephrase can heal a server crash. Propagate the error
1515
+ // out of the flow loop so main()'s outer try/catch reports the
1516
+ // diagnostic — bypasses the trailing-grace and replan paths
1517
+ // entirely. (The cascade-exhausted and probe-absent kinds fall
1518
+ // through to the existing dispatcher below.)
1519
+ if (err.kind === "backend-error-unrecoverable") {
1520
+ logger.error(`backend error unrecoverable: ${err.message}; aborting run`);
1521
+ throw err;
1522
+ }
1523
+ if (err.kind === "wizard-regression") {
1524
+ // The wizard restarted; replanning against a reset page cannot recover
1525
+ // the lost progress. Bypass the replan dispatcher and abort.
1526
+ logger.error(`wizard regression: ${err.message}; aborting run`);
1527
+ throw err;
1528
+ }
1529
+ // Tier 1 — trailing-optional-step grace: when an OPTIONAL step at
1530
+ // trailing position fails verification AND a recent non-GET capture
1531
+ // returned 2xx, treat as a benign no-op exit. The flow's "real work"
1532
+ // already completed server-side (recent successful POST proves it);
1533
+ // the trailing step is a redundant tail that the cascade can't make
1534
+ // meaningful progress on (e.g. resume re-upload after the workflow
1535
+ // already ended, final Continue when the Submit button is in
1536
+ // "Saving..." state). Uses only flow-position metadata + capture HTTP
1537
+ // metadata — no content matching, no open-set patterns.
1538
+ //
1539
+ // When the flow declares submitEndpointPattern, the "recent 2xx" must
1540
+ // match it. Without this gate, the heuristic latches onto every
1541
+ // non-GET 2xx — including pre-submit interruption_check POSTs and
1542
+ // third-party analytics tracking pixels (Google Analytics, DoubleClick,
1543
+ // googletagmanager) that fire on form interaction events. Those look
1544
+ // exactly like real submissions by HTTP signal but don't represent the
1545
+ // actual application landing. Verified by reading captures from
1546
+ // /tmp/recon/graphql/ during a sweep: Presbyterian jobs hit this exact
1547
+ // failure — only interruption_check + GA tracking POSTs fired, no
1548
+ // integrated_apply, yet trailing-grace declared success.
1549
+ if (step.optional && i >= plan.length - flow_runner_1.TRAILING_GRACE_WINDOW) {
1550
+ // Trailing-grace check: did the submit actually land somewhere in
1551
+ // the recent capture history? Ask the same Haiku judge — it has
1552
+ // multi-signal reasoning to distinguish real submit POSTs from
1553
+ // analytics/tracking 2xx that look submission-shaped.
1554
+ const pageTitle = await page.title().catch(() => "");
1555
+ const trailingGraceVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
1556
+ client: anthropic,
1557
+ input: {
1558
+ pageUrl: page.url(),
1559
+ pageTitle,
1560
+ unfocusedObserve: [],
1561
+ networkCaptures: recentCaptureMeta,
1562
+ invalidMarkerCount: 0,
1563
+ ownBackendHostnames,
1564
+ successUrlFragments,
1565
+ successPageTitleHints,
1566
+ submittedStateSelectors,
1567
+ },
1568
+ captureFn,
1569
+ });
1570
+ if (trailingGraceVerdict?.verified) {
1571
+ logger.info(`step ${i + 1} optional + trailing position; judge verified recent submit (${trailingGraceVerdict.rationale}) — treating verification failure as benign no-op; recon complete`);
1572
+ break;
1573
+ }
1574
+ }
1575
+ if (!anthropic)
1576
+ throw err;
1577
+ // Cause-based replan budget. Probe replans are cheap (one observe +
1578
+ // one LLM call to detect "wrong page"), cascade replans are expensive
1579
+ // (four attempts × backoff + observe + LLM rephrase before we know
1580
+ // the step is unrecoverable). Separate budgets so cheap recoveries
1581
+ // don't eat into the budget reserved for expensive ones.
1582
+ const isProbe = err.kind === "probe-absent";
1583
+ const budget = isProbe ? MAX_PROBE_REPLANS : MAX_CASCADE_REPLANS;
1584
+ const usedSoFar = isProbe ? probeReplansUsed : cascadeReplansUsed;
1585
+ if (usedSoFar >= budget) {
1586
+ const kindsSummary = callsNdjsonPath !== null
1587
+ ? summarizeReplanFailureKinds({
1588
+ callsNdjsonPath,
1589
+ callType: call_types_1.CALL_TYPE_RECON_REPLAN,
1590
+ tailCount: budget * 2,
1591
+ })
1592
+ : "";
1593
+ const kindsSuffix = kindsSummary ? ` — ${kindsSummary}` : "";
1594
+ logger.error(`step ${i + 1} ${err.kind} replan budget exhausted (${usedSoFar}/${budget}); aborting${kindsSuffix}`);
1595
+ throw err;
1596
+ }
1597
+ const replanIndex = replanEvents.length + 1;
1598
+ const originalRemaining = plan.slice(i + 1);
1599
+ const dumpMatch = err.message.match(/see (\/[^\s]+)$/);
1600
+ const dumpPath = dumpMatch ? dumpMatch[1] : "";
1601
+ logger.warn(`step ${i + 1} terminally failed (${err.kind}); attempting global replan #${replanIndex} (${isProbe ? "probe" : "cascade"} budget ${usedSoFar + 1}/${budget})`);
1602
+ const rawNewSteps = await replanRemainingFlow({
1603
+ client: anthropic,
1604
+ originalFlow: flow.map((s) => s.instruction),
1605
+ completedSteps,
1606
+ failedStep: step.instruction,
1607
+ remainingSteps: originalRemaining.map((s) => s.instruction),
1608
+ failureDumpPath: dumpPath,
1609
+ page,
1610
+ stagehand,
1611
+ captureFn,
1612
+ recentCaptures,
1613
+ ownBackendHostnames,
1614
+ trajectory,
1615
+ priorReplans: replanEvents,
1616
+ });
1617
+ if (!rawNewSteps) {
1618
+ logger.error(`replan #${replanIndex} returned outcome=impossible or unparseable output; aborting`);
1619
+ throw err;
1620
+ }
1621
+ // Resume-from-failure: drop any replan bridge step that re-runs an
1622
+ // already-completed step (see filterCompletedFromReplan). Keeps the
1623
+ // failed step's re-emission. originalRemaining is re-appended below.
1624
+ const newSteps = filterCompletedFromReplan(rawNewSteps, completedSteps, step.instruction);
1625
+ const droppedCompleted = rawNewSteps.length - newSteps.length;
1626
+ if (droppedCompleted > 0) {
1627
+ logger.info(`replan #${replanIndex}: dropped ${droppedCompleted} bridge step(s) that re-ran already-completed steps`);
1628
+ }
1629
+ if (newSteps.length === 0) {
1630
+ logger.error(`replan #${replanIndex} produced only already-completed steps (nothing new to bridge); aborting`);
1631
+ throw err;
1632
+ }
1633
+ // Immediate no-progress guard: if the replan's only bridge is a
1634
+ // re-emission of the step that just failed, resuming re-runs the whole
1635
+ // cascade on the identical click that just exhausted it. Abort now
1636
+ // instead of waiting REPLAN_CYCLE_THRESHOLD repeats for the cycle
1637
+ // detector — that many dead cascades cost minutes of wall-clock.
1638
+ if (isReplanReproposingFailedStep(newSteps, step.instruction)) {
1639
+ const noProgressMessage = `replan #${replanIndex} re-proposed only the just-failed step ("${step.instruction.slice(0, 60)}") with no new bridge; resuming would re-fail identically — aborting`;
1640
+ logger.error(noProgressMessage);
1641
+ throw new errors_2.StepVerificationError(noProgressMessage, "replan-cycle-detected");
1642
+ }
1643
+ const currentPageState = await (0, flow_runner_1.snapshotPage)(page, signalCounter).catch(() => ({
1644
+ url: page.url(),
1645
+ bodyHtmlLength: 0,
1646
+ }));
1647
+ if (isReplanCycle(replanEvents, newSteps, {
1648
+ url: currentPageState.url,
1649
+ htmlLength: currentPageState.bodyHtmlLength,
1650
+ })) {
1651
+ const cycleMessage = `replan cycle detected: identical proposal × ${REPLAN_CYCLE_THRESHOLD} under static page state; aborting`;
1652
+ logger.error(cycleMessage);
1653
+ throw new errors_2.StepVerificationError(cycleMessage, "replan-cycle-detected");
1654
+ }
1655
+ if (isProbe) {
1656
+ probeReplansUsed++;
1657
+ }
1658
+ else {
1659
+ cascadeReplansUsed++;
1660
+ }
1661
+ // err.kind narrowed to the two replan-bearing variants here: the
1662
+ // backend-error-unrecoverable dispatcher above throws out, and the
1663
+ // cycle-detected variant is only constructed at the throw site just
1664
+ // above this push — never caught back here.
1665
+ replanEvents.push({
1666
+ replanIndex,
1667
+ cause: err.kind,
1668
+ indexAtFailure: i,
1669
+ failedInstruction: step.instruction,
1670
+ replanSteps: newSteps,
1671
+ timestamp: (0, date_fns_1.formatISO)(new Date()),
1672
+ pageState: {
1673
+ url: currentPageState.url,
1674
+ htmlLength: currentPageState.bodyHtmlLength,
1675
+ },
1676
+ });
1677
+ const replanPath = dumpReplanRecord({
1678
+ stepIndex: i,
1679
+ phase: currentPhase,
1680
+ replanIndex,
1681
+ completedSteps,
1682
+ originalRemaining: originalRemaining.map((s) => s.instruction),
1683
+ newRemaining: newSteps.map((s) => s.instruction),
1684
+ });
1685
+ logger.info(`replan #${replanIndex} produced ${newSteps.length} new step(s); resuming (record: ${replanPath})`);
1686
+ for (const [j, s] of newSteps.entries()) {
1687
+ logger.info(` replanned step ${j + 1}${s.optional ? " (optional)" : ""}: ${s.instruction}`);
1688
+ }
1689
+ // Prepend recovery steps before the original remaining tail — the
1690
+ // replanner emits bridge steps from the failure point back to where
1691
+ // the original flow can resume. Idempotent fills/clicks on already-
1692
+ // satisfied form fields cost a few seconds each but keep the rest of
1693
+ // the original intent (page-0 Continue, page-1 sections, resume
1694
+ // upload, final submit) intact instead of replacing them with the
1695
+ // replanner's necessarily-truncated tail (capped at REPLAN_MAX_STEPS).
1696
+ // Tag replan-discovered steps with origin so persistReplannedFlow
1697
+ // can force them optional on write-back. originalRemaining keeps its
1698
+ // origin: "original" — that's what protects the canonical final
1699
+ // submit from being silently demoted to optional across replans.
1700
+ const taggedNewSteps = newSteps.map((s) => ({ ...s, origin: "replan" }));
1701
+ plan.splice(i, plan.length - i, ...taggedNewSteps, ...originalRemaining);
1702
+ i--;
1703
+ }
1704
+ }
1705
+ stopCapture();
1706
+ // End-of-run audit: when the flow declared submitEndpointPattern AND
1707
+ // opted into requireSubmitEndpointMatch=true, scan ALL captures from
1708
+ // this run for a pattern-matching 200 before declaring success. If no
1709
+ // match, the run "succeeded" by the verifier's lights but the actual
1710
+ // submission didn't land. Exit non-zero so the caller (test harness,
1711
+ // CI, or production runner) can distinguish silent-pass from real
1712
+ // success. This closes the loop the silent-pass bug exposed on 2026-
1713
+ // 06-09: per-step verifier accepted DOM-fallback as proof; run-level
1714
+ // audit catches that the network proof never actually arrived.
1715
+ if (requireSubmitEndpointMatch && ownBackendHostnames.length > 0) {
1716
+ const { auditFailed, rejectionReason } = auditFinalSubmitMatch({
1717
+ ownBackendHostnames,
1718
+ capturesDir: recon_shared_1.CAPTURES_DIR,
1719
+ logger,
1720
+ });
1721
+ if (auditFailed) {
1722
+ const reasonSuffix = rejectionReason
1723
+ ? ` — server REJECTED submission with rejection envelope (reason: "${rejectionReason}"); HTTP layer succeeded but application was not accepted`
1724
+ : ` — no captured 2xx had hostname in ${JSON.stringify(ownBackendHostnames)} — submission did not land despite verifier success`;
1725
+ logger.error(`end-of-run audit FAILED${reasonSuffix}`);
1726
+ // Exit non-zero so the runner counts this as a real failure rather
1727
+ // than rolling silent-pass forward as success.
1728
+ process.exit(1);
1729
+ }
1730
+ logger.info(`end-of-run audit PASSED: at least one captured 2xx matched submitEndpointPattern with clean response body`);
1731
+ }
1732
+ logger.info(`recon complete — ${counter.n} captures written to ${recon_shared_1.CAPTURES_DIR}`);
1733
+ }
1734
+ finally {
1735
+ // Replay-the-discovered-path: if any replan fired and the user provided
1736
+ // a flow file, write the improved plan back so the next run starts
1737
+ // where this one ended up. Runs INSIDE finally so the cascade's
1738
+ // discoveries survive cascade-exhausted exits too — the persistence
1739
+ // mechanism is the way recon self-heals the flow across runs, so it
1740
+ // needs to fire on failure as much as on success. Skipped on
1741
+ // --no-save-replan (diagnostic dry-runs) and when --flow was used
1742
+ // inline (no file to write back to).
1743
+ if (replanEvents.length > 0) {
1744
+ if (!saveReplan) {
1745
+ logger.info(`run done with ${replanEvents.length} replan event(s); --no-save-replan, leaving flow.json unchanged`);
1746
+ }
1747
+ else if (!flowFile) {
1748
+ logger.info(`run done with ${replanEvents.length} replan event(s); --flow used (no file to write back to)`);
1749
+ }
1750
+ else {
1751
+ logger.info(`run done; writing flow.json with ${replanEvents.length} replan event(s)`);
1752
+ try {
1753
+ persistReplannedFlow({
1754
+ flowFile,
1755
+ finalPlan: plan,
1756
+ replanEvents,
1757
+ logger,
1758
+ originalShape,
1759
+ submitEndpointPattern,
1760
+ submittedStateSelectors,
1761
+ requireSubmitEndpointMatch,
1762
+ successUrlFragments,
1763
+ successPageTitleHints,
1764
+ ownBackendHostnames,
1765
+ knownErrorClassPrefixes,
1766
+ });
1767
+ }
1768
+ catch (err) {
1769
+ // Persistence is best-effort in the finally block — a write
1770
+ // failure here must not eat the original cascade error.
1771
+ logger.error(`persistReplannedFlow threw in finally: ${(0, errors_1.toErrorMessage)(err)}`);
1772
+ }
1773
+ }
1774
+ }
1775
+ await session.close();
1776
+ }
1777
+ }
1778
+ if (process.argv[1] !== undefined &&
1779
+ (process.argv[1].endsWith("recon-browser.ts") || process.argv[1].endsWith("recon-browser.js"))) {
1780
+ main().catch((err) => {
1781
+ // StagehandDefaultError wraps the real cause with a verbose "Hey! We're sorry..." banner.
1782
+ // Unwrap it so the log shows just the meaningful error message.
1783
+ const message = err instanceof Error && err.cause instanceof Error ? err.cause.message : (0, errors_1.toErrorMessage)(err);
1784
+ logger.error(`recon-browser failed: ${message}`);
1785
+ process.exit(1);
1786
+ });
1787
+ }
1788
+ // Re-export shim: the step-execution engine now lives in `@/scraper/flow-runner`
1789
+ // (extracted from this module). Recon's tests and any downstream importer keep
1790
+ // importing these symbols from `@/scripts/recon-browser` unchanged.
1791
+ var flow_runner_2 = require("../scraper/flow-runner");
1792
+ Object.defineProperty(exports, "buildRadioIdXPath", { enumerable: true, get: function () { return flow_runner_2.buildRadioIdXPath; } });
1793
+ Object.defineProperty(exports, "capturesAfterIndex", { enumerable: true, get: function () { return flow_runner_2.capturesAfterIndex; } });
1794
+ Object.defineProperty(exports, "chooseRequiredSelectOption", { enumerable: true, get: function () { return flow_runner_2.chooseRequiredSelectOption; } });
1795
+ Object.defineProperty(exports, "describeAttemptEffectSignals", { enumerable: true, get: function () { return flow_runner_2.describeAttemptEffectSignals; } });
1796
+ Object.defineProperty(exports, "extractGaEventEvidence", { enumerable: true, get: function () { return flow_runner_2.extractGaEventEvidence; } });
1797
+ Object.defineProperty(exports, "extractSubmitFailureEvidence", { enumerable: true, get: function () { return flow_runner_2.extractSubmitFailureEvidence; } });
1798
+ Object.defineProperty(exports, "fillHtml5DateTimeInput", { enumerable: true, get: function () { return flow_runner_2.fillHtml5DateTimeInput; } });
1799
+ Object.defineProperty(exports, "findRecentBackendError", { enumerable: true, get: function () { return flow_runner_2.findRecentBackendError; } });
1800
+ Object.defineProperty(exports, "findRecentPageTransition", { enumerable: true, get: function () { return flow_runner_2.findRecentPageTransition; } });
1801
+ Object.defineProperty(exports, "formatValidationRejectedReason", { enumerable: true, get: function () { return flow_runner_2.formatValidationRejectedReason; } });
1802
+ Object.defineProperty(exports, "hasBillingErrorBeenLogged", { enumerable: true, get: function () { return flow_runner_2.hasBillingErrorBeenLogged; } });
1803
+ Object.defineProperty(exports, "isAdvanceStalled", { enumerable: true, get: function () { return flow_runner_2.isAdvanceStalled; } });
1804
+ Object.defineProperty(exports, "isAdvanceStep", { enumerable: true, get: function () { return flow_runner_2.isAdvanceStep; } });
1805
+ Object.defineProperty(exports, "isDomOnlyAdvanceVerified", { enumerable: true, get: function () { return flow_runner_2.isDomOnlyAdvanceVerified; } });
1806
+ Object.defineProperty(exports, "isSubmitRevealedInvalid", { enumerable: true, get: function () { return flow_runner_2.isSubmitRevealedInvalid; } });
1807
+ Object.defineProperty(exports, "isUploadAffordanceLabel", { enumerable: true, get: function () { return flow_runner_2.isUploadAffordanceLabel; } });
1808
+ Object.defineProperty(exports, "isWizardExitAction", { enumerable: true, get: function () { return flow_runner_2.isWizardExitAction; } });
1809
+ Object.defineProperty(exports, "latestCaptureIndex", { enumerable: true, get: function () { return flow_runner_2.latestCaptureIndex; } });
1810
+ Object.defineProperty(exports, "logBillingErrorIfPresent", { enumerable: true, get: function () { return flow_runner_2.logBillingErrorIfPresent; } });
1811
+ Object.defineProperty(exports, "narrowInvalidFormControl", { enumerable: true, get: function () { return flow_runner_2.narrowInvalidFormControl; } });
1812
+ Object.defineProperty(exports, "normalizeDateValue", { enumerable: true, get: function () { return flow_runner_2.normalizeDateValue; } });
1813
+ Object.defineProperty(exports, "pairInvalidWithErrors", { enumerable: true, get: function () { return flow_runner_2.pairInvalidWithErrors; } });
1814
+ Object.defineProperty(exports, "parseCaptureIndex", { enumerable: true, get: function () { return flow_runner_2.parseCaptureIndex; } });
1815
+ Object.defineProperty(exports, "parseRadioStep", { enumerable: true, get: function () { return flow_runner_2.parseRadioStep; } });
1816
+ Object.defineProperty(exports, "parseSelectStep", { enumerable: true, get: function () { return flow_runner_2.parseSelectStep; } });
1817
+ Object.defineProperty(exports, "pollEnumerate", { enumerable: true, get: function () { return flow_runner_2.pollEnumerate; } });
1818
+ Object.defineProperty(exports, "probeLeafInvalidContainers", { enumerable: true, get: function () { return flow_runner_2.probeLeafInvalidContainers; } });
1819
+ Object.defineProperty(exports, "probeStepBeforeAttempts", { enumerable: true, get: function () { return flow_runner_2.probeStepBeforeAttempts; } });
1820
+ Object.defineProperty(exports, "renderLeafInvalidFields", { enumerable: true, get: function () { return flow_runner_2.renderLeafInvalidFields; } });
1821
+ Object.defineProperty(exports, "renderUnfocusedObserve", { enumerable: true, get: function () { return flow_runner_2.renderUnfocusedObserve; } });
1822
+ Object.defineProperty(exports, "rephraseWithLLM", { enumerable: true, get: function () { return flow_runner_2.rephraseWithLLM; } });
1823
+ Object.defineProperty(exports, "resetBillingErrorFlagForTests", { enumerable: true, get: function () { return flow_runner_2.resetBillingErrorFlagForTests; } });
1824
+ Object.defineProperty(exports, "selectBodyExcerpt", { enumerable: true, get: function () { return flow_runner_2.selectBodyExcerpt; } });
1825
+ Object.defineProperty(exports, "selectRadioGroupOption", { enumerable: true, get: function () { return flow_runner_2.selectRadioGroupOption; } });
1826
+ Object.defineProperty(exports, "shouldSkipTechnique", { enumerable: true, get: function () { return flow_runner_2.shouldSkipTechnique; } });
1827
+ Object.defineProperty(exports, "shouldVetoFallbackAdvance", { enumerable: true, get: function () { return flow_runner_2.shouldVetoFallbackAdvance; } });
1828
+ Object.defineProperty(exports, "verifyFillReadback", { enumerable: true, get: function () { return flow_runner_2.verifyFillReadback; } });
1829
+ Object.defineProperty(exports, "waitForTransitionBody", { enumerable: true, get: function () { return flow_runner_2.waitForTransitionBody; } });
1830
+ Object.defineProperty(exports, "windowHasAdvanceTransition", { enumerable: true, get: function () { return flow_runner_2.windowHasAdvanceTransition; } });
1831
+ Object.defineProperty(exports, "windowHasTransitionBody", { enumerable: true, get: function () { return flow_runner_2.windowHasTransitionBody; } });
1832
+ Object.defineProperty(exports, "writeFixtureToTempFile", { enumerable: true, get: function () { return flow_runner_2.writeFixtureToTempFile; } });
1833
+ //# sourceMappingURL=recon-browser.js.map