@reticlehq/server 3.5.0 → 3.6.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 (328) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +16 -8
  3. package/dist/chromium-launch-options.d.ts +3 -1
  4. package/dist/chromium-launch-options.js +2 -1
  5. package/dist/command/cli/adhoc-verdict.d.ts +1 -0
  6. package/dist/command/cli/adhoc-verdict.js +6 -3
  7. package/dist/command/cli/answers/open-note.d.ts +2 -0
  8. package/dist/command/cli/answers/open-note.js +22 -0
  9. package/dist/command/cli/cli-flow-commands.js +4 -8
  10. package/dist/command/cli/cli-parse.d.ts +1 -1
  11. package/dist/command/cli/cli-parse.js +10 -10
  12. package/dist/command/cli/cli-verify.d.ts +1 -1
  13. package/dist/command/cli/cli-verify.js +35 -25
  14. package/dist/command/cli/cloud-cli.js +51 -12
  15. package/dist/command/cli/cloud-login.d.ts +8 -0
  16. package/dist/command/cli/cloud-login.js +36 -2
  17. package/dist/command/cli/demo/demo-page.js +11 -3
  18. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +46 -0
  19. package/dist/command/cli/doctor/browser/chromium-hint.js +99 -1
  20. package/dist/command/cli/drive/drive-attach.d.ts +27 -2
  21. package/dist/command/cli/drive/drive-attach.js +71 -4
  22. package/dist/command/cli/drive/drive-command.d.ts +2 -1
  23. package/dist/command/cli/drive/drive-command.js +13 -7
  24. package/dist/command/cli/lifecycle/daemon-lifecycle.js +4 -1
  25. package/dist/command/cli/ports/port-holder.d.ts +10 -0
  26. package/dist/command/cli/ports/port-holder.js +24 -0
  27. package/dist/command/cli/setup-mcp-cli.js +6 -4
  28. package/dist/command/cli/tutorial.d.ts +2 -2
  29. package/dist/command/cli/tutorial.js +6 -9
  30. package/dist/command/cli.d.ts +6 -0
  31. package/dist/command/cli.js +28 -22
  32. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  33. package/dist/command/daemon/binding/daemon-status-probe.js +1 -0
  34. package/dist/command/daemon/daemon-resolve.d.ts +14 -0
  35. package/dist/command/daemon/daemon-resolve.js +33 -2
  36. package/dist/command/setup/agent-writer.d.ts +1 -1
  37. package/dist/command/setup/agent-writer.js +3 -2
  38. package/dist/command/setup/bringup/connect-lease.d.ts +1 -0
  39. package/dist/command/setup/bringup/connect-lease.js +1 -0
  40. package/dist/command/setup/bringup/dev-server-wait.d.ts +8 -0
  41. package/dist/command/setup/bringup/dev-server-wait.js +19 -0
  42. package/dist/command/setup/init/init-port.d.ts +4 -0
  43. package/dist/command/setup/init/init-port.js +10 -0
  44. package/dist/command/setup/init/init-runtime.d.ts +10 -0
  45. package/dist/command/setup/init/init-runtime.js +18 -1
  46. package/dist/command/setup/node-effects.js +2 -1
  47. package/dist/command/setup/remaining-steps.js +2 -1
  48. package/dist/command/setup/run-setup.d.ts +5 -0
  49. package/dist/command/setup/run-setup.js +35 -27
  50. package/dist/command/setup/setup-command.d.ts +2 -1
  51. package/dist/command/setup/setup-command.js +2 -0
  52. package/dist/command/version/version-skew.js +11 -2
  53. package/dist/features/crawl/crawl-tools.js +4 -0
  54. package/dist/features/crawl/crawl.d.ts +6 -0
  55. package/dist/features/crawl/crawl.js +9 -1
  56. package/dist/features/exhaust/ledger.d.ts +7 -0
  57. package/dist/features/exhaust/ledger.js +35 -9
  58. package/dist/features/harness/app-findings.d.ts +2 -0
  59. package/dist/features/harness/app-findings.js +52 -0
  60. package/dist/features/harness/drive-report.d.ts +27 -1
  61. package/dist/features/harness/drive-report.js +162 -11
  62. package/dist/features/harness/drivers.d.ts +10 -14
  63. package/dist/features/harness/drivers.js +10 -14
  64. package/dist/features/harness/fill-values.d.ts +3 -73
  65. package/dist/features/harness/fill-values.js +3 -169
  66. package/dist/features/harness/goals.d.ts +21 -0
  67. package/dist/features/harness/goals.js +77 -0
  68. package/dist/features/harness/harness.d.ts +21 -0
  69. package/dist/features/harness/harness.js +69 -2
  70. package/dist/features/harness/platform/drive-target.d.ts +30 -0
  71. package/dist/features/harness/platform/drive-target.js +81 -0
  72. package/dist/features/harness/platform/local-apps.d.ts +94 -0
  73. package/dist/features/harness/platform/local-apps.js +204 -0
  74. package/dist/features/harness/platform/remote-drive.d.ts +180 -0
  75. package/dist/features/harness/platform/remote-drive.js +365 -0
  76. package/dist/features/harness/platform/script.d.ts +48 -0
  77. package/dist/features/harness/platform/script.js +128 -0
  78. package/dist/features/harness/platform/server-driver.d.ts +41 -0
  79. package/dist/features/harness/platform/server-driver.js +179 -0
  80. package/dist/features/harness/platform-config.d.ts +7 -0
  81. package/dist/features/harness/platform-config.js +14 -2
  82. package/dist/features/harness/script/app-controls.d.ts +5 -0
  83. package/dist/features/harness/script/app-controls.js +40 -0
  84. package/dist/features/harness/script/lane-ids.d.ts +14 -0
  85. package/dist/features/harness/script/lane-ids.js +16 -0
  86. package/dist/features/harness/script/script-run.d.ts +41 -0
  87. package/dist/features/harness/script/script-run.js +299 -0
  88. package/dist/features/license/license.d.ts +2 -2
  89. package/dist/features/visual/visual-tools.d.ts +10 -1
  90. package/dist/features/visual/visual-tools.js +40 -3
  91. package/dist/hooks/driven-by.d.ts +4 -0
  92. package/dist/hooks/driven-by.js +16 -0
  93. package/dist/hooks/hook-commands.js +15 -4
  94. package/dist/hooks/tool-log.d.ts +9 -0
  95. package/dist/hooks/tool-log.js +29 -0
  96. package/dist/index.d.ts +1 -0
  97. package/dist/index.js +76 -82
  98. package/dist/judgement/runs/artifact/build-verification-run.js +6 -0
  99. package/dist/judgement/runs/artifact/replay-mapping.d.ts +1 -1
  100. package/dist/judgement/runs/artifact/replay-mapping.js +16 -6
  101. package/dist/judgement/runs/artifact/run-store.d.ts +1 -1
  102. package/dist/judgement/runs/artifact/run-store.js +26 -1
  103. package/dist/judgement/runs/drive-run.d.ts +22 -0
  104. package/dist/judgement/runs/drive-run.js +99 -1
  105. package/dist/judgement/runs/repair-prompt.js +1 -1
  106. package/dist/judgement/runs/reticle-runner.js +8 -1
  107. package/dist/judgement/runs/verification-sync.d.ts +3 -2
  108. package/dist/judgement/runs/verification-sync.js +4 -3
  109. package/dist/language/flows/change/gate.d.ts +10 -0
  110. package/dist/language/flows/change/gate.js +22 -0
  111. package/dist/language/flows/decision.js +34 -0
  112. package/dist/language/flows/flow-anchor.d.ts +54 -8
  113. package/dist/language/flows/flow-anchor.js +88 -3
  114. package/dist/language/flows/flow-cross-step.d.ts +15 -0
  115. package/dist/language/flows/flow-cross-step.js +21 -0
  116. package/dist/language/flows/flow-learning.js +8 -11
  117. package/dist/language/flows/flow-mutate-tools.js +5 -3
  118. package/dist/language/flows/flow-replay-run.d.ts +23 -9
  119. package/dist/language/flows/flow-replay-run.js +101 -94
  120. package/dist/language/flows/flow-replay.d.ts +23 -1
  121. package/dist/language/flows/flow-replay.js +92 -19
  122. package/dist/language/flows/flow-scope.js +5 -1
  123. package/dist/language/flows/flow-step-runners.d.ts +20 -2
  124. package/dist/language/flows/flow-step-runners.js +236 -42
  125. package/dist/language/flows/flow-store-for-session.d.ts +2 -2
  126. package/dist/language/flows/flow-store-for-session.js +2 -2
  127. package/dist/language/flows/flow-tools.js +58 -34
  128. package/dist/language/flows/flows.js +5 -2
  129. package/dist/language/flows/heal-run.js +6 -4
  130. package/dist/language/flows/recording/flake.d.ts +2 -2
  131. package/dist/language/flows/recording/tape/recordings.d.ts +30 -4
  132. package/dist/language/flows/recording/tape/recordings.js +40 -8
  133. package/dist/language/flows/replay-from-hud.d.ts +20 -0
  134. package/dist/language/flows/replay-from-hud.js +51 -0
  135. package/dist/language/flows/replay.d.ts +9 -5
  136. package/dist/language/flows/replay.js +27 -9
  137. package/dist/language/flows/suite/suite-flakes.js +2 -0
  138. package/dist/language/flows/suite-selection.d.ts +2 -15
  139. package/dist/language/flows/suite-selection.js +4 -3
  140. package/dist/language/flows/verify-change-tools.js +8 -4
  141. package/dist/launch-chromium.d.ts +27 -5
  142. package/dist/launch-chromium.js +38 -8
  143. package/dist/memory/cloud/cloud-config.d.ts +12 -0
  144. package/dist/memory/cloud/cloud-config.js +15 -0
  145. package/dist/memory/cloud/cloud-sync.d.ts +17 -14
  146. package/dist/memory/cloud/cloud-sync.js +12 -1
  147. package/dist/memory/cloud/harness-config.d.ts +26 -1
  148. package/dist/memory/cloud/harness-config.js +99 -4
  149. package/dist/memory/cloud/hud-notices-source.d.ts +21 -0
  150. package/dist/memory/cloud/hud-notices-source.js +128 -0
  151. package/dist/memory/cloud/memory-scope.d.ts +144 -0
  152. package/dist/memory/cloud/memory-scope.js +205 -0
  153. package/dist/memory/cloud/sync-cycle.d.ts +12 -3
  154. package/dist/memory/cloud/sync-cycle.js +16 -3
  155. package/dist/memory/cloud/sync-daemon.js +7 -5
  156. package/dist/memory/cloud/sync-disk.d.ts +1 -1
  157. package/dist/memory/cloud/sync-disk.js +5 -8
  158. package/dist/memory/impact/account-watch.d.ts +19 -0
  159. package/dist/memory/impact/account-watch.js +34 -0
  160. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  161. package/dist/memory/impact/impact-recorder.js +12 -2
  162. package/dist/memory/impact/impact-store.d.ts +16 -0
  163. package/dist/memory/impact/impact-store.js +142 -26
  164. package/dist/memory/intent/intent-shard.d.ts +2 -2
  165. package/dist/memory/intent/intent-tools.js +80 -2
  166. package/dist/memory/journal/envelope.d.ts +8 -8
  167. package/dist/memory/journal/journal-recorder.js +7 -1
  168. package/dist/memory/journal/on-disk/retention.d.ts +3 -0
  169. package/dist/memory/journal/on-disk/retention.js +5 -0
  170. package/dist/memory/journal/on-disk/startup-maintenance.js +3 -1
  171. package/dist/memory/journal/on-disk/workspace-tiers.js +7 -4
  172. package/dist/memory/journal/session-end.js +6 -4
  173. package/dist/memory/project/artifact-root-resolver.d.ts +26 -1
  174. package/dist/memory/project/artifact-root-resolver.js +94 -13
  175. package/dist/memory/project/artifact-root.d.ts +42 -5
  176. package/dist/memory/project/artifact-root.js +87 -8
  177. package/dist/memory/project/dir/reticle-dir.d.ts +4 -2
  178. package/dist/memory/project/dir/reticle-dir.js +3 -1
  179. package/dist/memory/project/project-tools.js +22 -3
  180. package/dist/memory/project/session-root.d.ts +24 -1
  181. package/dist/memory/project/session-root.js +41 -5
  182. package/dist/memory/project/sync-status.d.ts +39 -0
  183. package/dist/memory/project/sync-status.js +90 -0
  184. package/dist/memory/recall/memory-tools.js +2 -0
  185. package/dist/memory/recall/project-memory.d.ts +19 -0
  186. package/dist/memory/recall/project-memory.js +45 -6
  187. package/dist/portal/bridge/bridge.d.ts +3 -1
  188. package/dist/portal/bridge/bridge.js +19 -2
  189. package/dist/portal/bridge/pairing-token.d.ts +9 -0
  190. package/dist/portal/bridge/pairing-token.js +17 -0
  191. package/dist/portal/input/real-input.d.ts +81 -10
  192. package/dist/portal/input/real-input.js +207 -22
  193. package/dist/portal/input/viewport-tools.js +3 -3
  194. package/dist/portal/pool/browser-pool.d.ts +22 -9
  195. package/dist/portal/pool/browser-pool.js +70 -35
  196. package/dist/portal/pool/playwright-launcher.d.ts +13 -0
  197. package/dist/portal/pool/playwright-launcher.js +17 -1
  198. package/dist/portal/pool/pool-contract.d.ts +7 -4
  199. package/dist/portal/session/command-payload.d.ts +8 -0
  200. package/dist/portal/session/command-payload.js +19 -0
  201. package/dist/portal/session/dev-server/dev-command.d.ts +12 -0
  202. package/dist/portal/session/dev-server/dev-command.js +61 -12
  203. package/dist/portal/session/facts/departure-note.d.ts +22 -0
  204. package/dist/portal/session/facts/departure-note.js +47 -0
  205. package/dist/portal/session/facts/session-replaced.d.ts +5 -0
  206. package/dist/portal/session/facts/session-replaced.js +5 -0
  207. package/dist/portal/session/first-run-wiring.d.ts +42 -0
  208. package/dist/portal/session/first-run-wiring.js +132 -0
  209. package/dist/portal/session/human/notes-ledger.d.ts +20 -0
  210. package/dist/portal/session/human/notes-ledger.js +55 -0
  211. package/dist/portal/session/human/review-store.d.ts +12 -0
  212. package/dist/portal/session/human/review-store.js +18 -10
  213. package/dist/portal/session/live-control-tools.d.ts +0 -13
  214. package/dist/portal/session/live-control-tools.js +28 -3
  215. package/dist/portal/session/no-session-diagnosis.d.ts +7 -0
  216. package/dist/portal/session/no-session-diagnosis.js +15 -0
  217. package/dist/portal/session/no-session-next-action.d.ts +11 -0
  218. package/dist/portal/session/no-session-next-action.js +31 -5
  219. package/dist/portal/session/no-session-watch.d.ts +14 -1
  220. package/dist/portal/session/no-session-watch.js +63 -10
  221. package/dist/portal/session/presence/session-mirror.js +3 -0
  222. package/dist/portal/session/serving-directory.d.ts +27 -0
  223. package/dist/portal/session/serving-directory.js +45 -0
  224. package/dist/portal/session/session-health.d.ts +7 -0
  225. package/dist/portal/session/session-health.js +6 -0
  226. package/dist/portal/session/session-info.d.ts +8 -1
  227. package/dist/portal/session/session-info.js +6 -0
  228. package/dist/portal/session/session-manager.d.ts +21 -0
  229. package/dist/portal/session/session-manager.js +44 -8
  230. package/dist/portal/session/session-reaper.js +4 -1
  231. package/dist/portal/session/session-successor.d.ts +5 -0
  232. package/dist/portal/session/session-tools.js +26 -3
  233. package/dist/portal/session/session.d.ts +7 -2
  234. package/dist/portal/session/session.js +21 -18
  235. package/dist/start-options.d.ts +6 -0
  236. package/dist/status-payload.d.ts +10 -0
  237. package/dist/status-payload.js +23 -0
  238. package/dist/surface/http-server.js +11 -1
  239. package/dist/surface/mcp/mcp-proxy.d.ts +5 -0
  240. package/dist/surface/mcp/mcp-proxy.js +36 -5
  241. package/dist/surface/mcp/mcp.d.ts +11 -6
  242. package/dist/surface/mcp/mcp.js +27 -13
  243. package/dist/surface/mcp/server-instructions.d.ts +4 -2
  244. package/dist/surface/mcp/server-instructions.js +14 -4
  245. package/dist/surface/mcp/shared-params.js +2 -2
  246. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +8 -0
  247. package/dist/surface/tools/act/navigation/navigate-arrival.js +56 -2
  248. package/dist/surface/tools/act/settle-in-flight.js +25 -3
  249. package/dist/surface/tools/act-sequence-tool.js +2 -2
  250. package/dist/surface/tools/act-tools.js +23 -7
  251. package/dist/surface/tools/assert/assert-verdict.d.ts +1 -1
  252. package/dist/surface/tools/assert/assert-verdict.js +17 -2
  253. package/dist/surface/tools/browser-tools.js +8 -1
  254. package/dist/surface/tools/chat-drives.d.ts +26 -0
  255. package/dist/surface/tools/chat-drives.js +165 -0
  256. package/dist/surface/tools/empty-session-list.d.ts +41 -0
  257. package/dist/surface/tools/empty-session-list.js +80 -0
  258. package/dist/surface/tools/error-recovery.d.ts +6 -0
  259. package/dist/surface/tools/error-recovery.js +37 -4
  260. package/dist/surface/tools/explore-tools.d.ts +10 -1
  261. package/dist/surface/tools/explore-tools.js +82 -13
  262. package/dist/surface/tools/feedback-tools.js +14 -0
  263. package/dist/surface/tools/harness-explore.d.ts +96 -14
  264. package/dist/surface/tools/harness-explore.js +227 -206
  265. package/dist/surface/tools/harness-plan.d.ts +12 -0
  266. package/dist/surface/tools/harness-plan.js +31 -0
  267. package/dist/surface/tools/harness-script.d.ts +8 -0
  268. package/dist/surface/tools/harness-script.js +342 -0
  269. package/dist/surface/tools/harness-toolset.d.ts +14 -1
  270. package/dist/surface/tools/harness-toolset.js +77 -6
  271. package/dist/surface/tools/invoke-tool.js +52 -1
  272. package/dist/surface/tools/lease-seed.d.ts +29 -0
  273. package/dist/surface/tools/lease-seed.js +123 -0
  274. package/dist/surface/tools/lease-tools.d.ts +3 -23
  275. package/dist/surface/tools/lease-tools.js +30 -131
  276. package/dist/surface/tools/live-call-text.js +2 -2
  277. package/dist/surface/tools/next-step.d.ts +19 -0
  278. package/dist/surface/tools/next-step.js +159 -0
  279. package/dist/surface/tools/observe-tools.js +2 -4
  280. package/dist/surface/tools/platform-moment.d.ts +12 -0
  281. package/dist/surface/tools/platform-moment.js +168 -0
  282. package/dist/surface/tools/query-hint-schema.d.ts +7 -0
  283. package/dist/surface/tools/query-hint-schema.js +10 -0
  284. package/dist/surface/tools/read-tools.js +8 -2
  285. package/dist/surface/tools/real-input-attempt.js +96 -13
  286. package/dist/surface/tools/tool-kit.d.ts +15 -1
  287. package/dist/surface/tools/tool-kit.js +4 -6
  288. package/dist/surface/tools/tools.js +20 -48
  289. package/dist/telemetry/hud-metrics.d.ts +2 -0
  290. package/dist/telemetry/hud-metrics.js +17 -0
  291. package/dist/telemetry/telemetry.d.ts +12 -0
  292. package/dist/telemetry/telemetry.js +43 -14
  293. package/dist/telemetry/verification-of.js +1 -0
  294. package/dist/wire-journal.js +4 -1
  295. package/docs/README.md +1 -1
  296. package/docs/agent-cheatsheet.md +1 -1
  297. package/docs/autodrive.md +29 -25
  298. package/docs/cli/doctor.mdx +6 -0
  299. package/docs/cli/drive.mdx +8 -2
  300. package/docs/cli/init.mdx +11 -13
  301. package/docs/cli/status.mdx +5 -1
  302. package/docs/cli/version.mdx +3 -3
  303. package/docs/cli.mdx +1 -1
  304. package/docs/desktop.mdx +5 -1
  305. package/docs/environment.mdx +3 -7
  306. package/docs/getting-started.md +13 -11
  307. package/docs/human-control.md +1 -2
  308. package/docs/index.mdx +4 -3
  309. package/docs/install-manual.mdx +2 -2
  310. package/docs/packages/tauri.mdx +2 -2
  311. package/docs/packages/vite-plugin.mdx +22 -1
  312. package/docs/packages.mdx +1 -1
  313. package/docs/quickstart.mdx +17 -7
  314. package/docs/telemetry.md +1 -1
  315. package/docs/tools/session-and-feedback.mdx +9 -9
  316. package/docs/usage.md +5 -2
  317. package/docs/what-is-recorded.md +13 -3
  318. package/docs/what-is-reticle.mdx +3 -1
  319. package/package.json +8 -12
  320. package/dist/features/harness/driver.d.ts +0 -50
  321. package/dist/features/harness/driver.js +0 -236
  322. package/dist/features/harness/jev-driver.d.ts +0 -188
  323. package/dist/features/harness/jev-driver.js +0 -678
  324. package/dist/features/harness/openai-driver.d.ts +0 -58
  325. package/dist/features/harness/openai-driver.js +0 -206
  326. package/dist/memory/project/dir/fill-value-store.d.ts +0 -32
  327. package/dist/memory/project/dir/fill-value-store.js +0 -88
  328. package/docs/local-registry.md +0 -143
@@ -1,11 +1,12 @@
1
1
  import { formatStepAddress } from 'open-verification';
2
2
  import { assertsState, expectLabel, expectedElementTestid, withoutClauses } from '@reticlehq/core';
3
3
  import { span } from '../../trace.js';
4
- import { anchorLabel, expectElementDrift, resolveTestid, testidDrift, ambiguousAnchorDrift, } from './flow-anchor.js';
4
+ import { ambiguityDrift, anchorLabel, expectElementDrift, reresolved, resolveTestid, testidDrift, } from './flow-anchor.js';
5
5
  export { nearestIsAmbiguous, nearestTestid, resolveQuery } from './flow-anchor.js';
6
6
  import { routeOfEvent, routeOfUrl } from '@reticlehq/engine/question/predicate/predicate-route.js';
7
+ import { evalConsole } from '@reticlehq/engine/question/predicate/predicate-console.js';
7
8
  import { stepEffect } from '@reticlehq/engine/evidence/step-effect.js';
8
- import { AnchorKind, DriftReason, EventType, FlowStepTool, StepEffect, PredicateKind, } from '@reticlehq/core';
9
+ import { AnchorKind, DriftReason, EventType, FlowStepTool, StepEffect, PredicateKind, ReplayStatus, } from '@reticlehq/core';
9
10
  import { asString, isConsequenceDrift } from '@reticlehq/core';
10
11
  import { anchorFieldName } from './fields/flow-secret-field.js';
11
12
  import { degradedStepResult, isDegradedAnchor, runComponentStep, runRoleStep, runSequenceStep, } from './flow-step-runners.js';
@@ -100,11 +101,48 @@ function summarizeConsequence(events) {
100
101
  const status = 'number' === typeof data['status'] ? ` ${data['status']}` : '';
101
102
  parts.push(`${method} ${path}${status}`.trim());
102
103
  }
103
- const errors = events.filter((e) => e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT).length;
104
+ const errors = events.filter(isConsoleError).length;
104
105
  if (errors > 0)
105
106
  parts.push(`${errors} console error${errors > 1 ? 's' : ''}`);
106
107
  return parts.length > 0 ? parts.join('; ') : undefined;
107
108
  }
109
+ const isConsoleError = (e) => e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT;
110
+ /** At most this many console errors travel on one step; its `digest` still counts them all. */
111
+ const CONSOLE_ERRORS_PER_STEP = 5;
112
+ /** The console errors a step's window saw, for the run artifact's evidence. See FlowStepResult. */
113
+ function consoleErrorsIn(events) {
114
+ const errors = events
115
+ .filter(isConsoleError)
116
+ .slice(0, CONSOLE_ERRORS_PER_STEP)
117
+ .map((e) => ({ level: e.type, message: asString(e.data['message']) ?? '', at: e.t }));
118
+ return errors.length > 0 ? errors : undefined;
119
+ }
120
+ /** The console-absent clauses an expect cannot pass without: itself, or any `allOf` member. */
121
+ function requiredConsoleAbsences(expect) {
122
+ if (PredicateKind.ALL_OF === expect.kind)
123
+ return expect.predicates.flatMap(requiredConsoleAbsences);
124
+ return PredicateKind.CONSOLE === expect.kind && true === expect.absent ? [expect] : [];
125
+ }
126
+ /**
127
+ * A console-absent expect, read again over the step's WHOLE window.
128
+ *
129
+ * The wait that graded the expect ends before the window does, so an error landing between the two
130
+ * was counted by the step's consequence and missed by its verdict (#1344). The window wins.
131
+ */
132
+ function lateConsoleDrift(expect, events) {
133
+ for (const clause of requiredConsoleAbsences(expect)) {
134
+ const read = evalConsole(events, clause);
135
+ if (read.pass)
136
+ continue;
137
+ return {
138
+ reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
139
+ reason: read.failureReason ?? "the step's declared consequence did not hold",
140
+ anchor: expectLabel(expect),
141
+ nearest: null,
142
+ };
143
+ }
144
+ return undefined;
145
+ }
108
146
  /** Run one testid-anchored step: re-resolve via QUERY, then ACT on the live ref, else drift. */
109
147
  export async function runTestidStep(session, step, index, value, dynamic, confirmDangerous, sleep) {
110
148
  const { refs, hint } = await resolveTestid(session, value, sleep);
@@ -124,17 +162,13 @@ export async function runTestidStep(session, step, index, value, dynamic, confir
124
162
  * reads `drift` and `ok` and never reads a note, so "we guessed which element you meant" was
125
163
  * indistinguishable from "it did what it did before" -- the one claim a replay makes.
126
164
  *
127
- * The action is NOT dispatched. Acting and then reporting drift would leave the app changed by
128
- * a click nobody can attribute, which is worse than the ambiguity it reports.
165
+ * The record itself comes from `ambiguityDrift`, shared with the role and component runners: this
166
+ * anchor kind was the first to get the rule, and writing it inline is how the other two came to
167
+ * answer the same question differently.
129
168
  */
130
- if (refs.length > 1) {
131
- return {
132
- step: index,
133
- tool: step.tool,
134
- anchor: value,
135
- ok: false,
136
- drift: ambiguousAnchorDrift(value, refs.length),
137
- };
169
+ const ambiguous = ambiguityDrift(step.anchor, refs);
170
+ if (ambiguous !== null) {
171
+ return { step: index, tool: step.tool, anchor: value, ok: false, drift: ambiguous };
138
172
  }
139
173
  const ref = refs[0] ?? '';
140
174
  /*
@@ -146,15 +180,12 @@ export async function runTestidStep(session, step, index, value, dynamic, confir
146
180
  * so the kind most likely to appear in a real flow was the kind without the cure.
147
181
  *
148
182
  * The re-resolve keeps the ambiguity rule above: more than one match is drift, never a guess, so
149
- * it hands back a ref only when the locator still names exactly one element.
183
+ * it reports an `anchor_ambiguous` drift rather than handing back the first of the new matches.
150
184
  */
151
185
  const result = await actOnResolvedRef(session, step, index, value, ref, confirmDangerous,
152
186
  // The field this step types into — from the anchor, so a redacted fill can be supplied from
153
187
  // RETICLE_SECRET_<FIELD> without the flow carrying the secret.
154
- anchorFieldName(step.anchor), async () => {
155
- const again = await resolveTestid(session, value, sleep);
156
- return 1 === again.refs.length ? again.refs[0] : undefined;
157
- });
188
+ anchorFieldName(step.anchor), async () => reresolved(step.anchor, (await resolveTestid(session, value, sleep)).refs));
158
189
  if (!result.ok)
159
190
  return result;
160
191
  // assert the step's expect.element testid is present AFTER the action —
@@ -297,6 +328,19 @@ export function resumeIndex(from, steps) {
297
328
  return undefined;
298
329
  return steps.slice(0, at).some((step) => StepEffect.COMMITS === step.effect) ? 0 : at;
299
330
  }
331
+ /** How far a replay drives: `to` stops before a step, `at` picks up at one. Positive indices only. */
332
+ export function replayWindow(args) {
333
+ const index = (raw) => 'number' === typeof raw && Number.isInteger(raw) && 0 < raw ? raw : undefined;
334
+ const to = index(args['to']);
335
+ const at = index(args['at']);
336
+ return { ...(to === undefined ? {} : { to }), ...(at === undefined ? {} : { at }) };
337
+ }
338
+ /** A part-way replay's status, from the steps it drove. */
339
+ export function partialStatus(steps) {
340
+ if (steps.some((step) => step.drift !== undefined))
341
+ return ReplayStatus.DRIFT;
342
+ return steps.every((step) => step.ok) ? ReplayStatus.OK : ReplayStatus.ERROR;
343
+ }
300
344
  /** The `from` argument, narrowed: a step index or a step id. */
301
345
  export function resumeArg(raw) {
302
346
  return 'number' === typeof raw || ('string' === typeof raw && raw.length > 0) ? raw : undefined;
@@ -342,7 +386,8 @@ async function runInvokeStep(session, flow, step, index, waitForSignal, signalTi
342
386
  }
343
387
  // `from` is an offset into the CALLER's steps. A sub-journey invoked from the setup prefix runs
344
388
  // whole and unchecked (from = its length); one invoked at or after the resume point runs normally.
345
- const { from: _outer, ...rest } = options;
389
+ // Nor the progress callback: a sub-flow's steps are one step of the flow the chip is drawing.
390
+ const { from: _outer, onStep: _progress, to: _to, at: _at, ...rest } = options;
346
391
  const carried = setup ? { ...rest, from: sub.steps.length } : rest;
347
392
  const nested = await replayFlow(session, sub, waitForSignal, signalTimeoutMs, confirmDangerous, sleep, {
348
393
  ...carried,
@@ -377,13 +422,28 @@ export async function replayFlow(session, flow, waitForSignal, signalTimeoutMs,
377
422
  // wait for the slowest step would trade a false red for a slow suite. See FlowStep.timeoutMs.
378
423
  const waitFor = (step) => step.timeoutMs ?? flow.signalTimeoutMs ?? signalTimeoutMs;
379
424
  let index = 0;
425
+ const tell = (result) => {
426
+ try {
427
+ options.onStep?.(index + 1, flow.steps.length, true === result?.ok && result.drift === undefined);
428
+ }
429
+ catch {
430
+ /* progress is a picture of the replay, never a reason to stop it */
431
+ }
432
+ };
380
433
  try {
381
434
  for (const step of flow.steps) {
435
+ if (options.to !== undefined && index >= options.to)
436
+ break;
437
+ if (options.at !== undefined && index < options.at) {
438
+ index += 1;
439
+ continue;
440
+ }
382
441
  if (step.invoke !== undefined) {
383
442
  results.push(await runInvokeStep(session, flow, step, index, waitForSignal, signalTimeoutMs, confirmDangerous, sleep, options, index < from));
384
443
  const last = results[results.length - 1];
385
444
  if (last !== undefined && index < from && last.ok)
386
445
  results.pop();
446
+ tell(last);
387
447
  if (last !== undefined && false === last.ok)
388
448
  break;
389
449
  index += 1;
@@ -459,6 +519,18 @@ export async function replayFlow(session, flow, waitForSignal, signalTimeoutMs,
459
519
  const consequence = summarizeConsequence(windowEvents);
460
520
  if (consequence !== undefined)
461
521
  result.consequence = consequence;
522
+ const consoleErrors = consoleErrorsIn(windowEvents);
523
+ if (consoleErrors !== undefined)
524
+ result.consoleErrors = consoleErrors;
525
+ for (const expectation of checked) {
526
+ if (!result.ok || result.drift !== undefined)
527
+ break;
528
+ const late = lateConsoleDrift(expectation, windowEvents);
529
+ if (late !== undefined) {
530
+ result.ok = false;
531
+ result.drift = late;
532
+ }
533
+ }
462
534
  // Per-step wall time is NOT shipped: it is `window.until - window.since`, computed from two numbers
463
535
  // the next line already puts in the same object, under the same emission condition. A step used to
464
536
  // carry the subtraction AND both operands, on every step of every replay.
@@ -484,6 +556,7 @@ export async function replayFlow(session, flow, waitForSignal, signalTimeoutMs,
484
556
  if (index < from)
485
557
  result.note = `setup step for resuming at step ${String(from)} failed`;
486
558
  results.push(result);
559
+ tell(result);
487
560
  // Under `sweep`, a failure whose action still RAN does not stop the run — the page is where the
488
561
  // step left it, so the next step is as meaningful as it was going to be. Anything else halts.
489
562
  const sweepPast = true === options.sweep &&
@@ -27,7 +27,11 @@ export function buildFlowChips(flows, sessionProjectId) {
27
27
  const start = first !== undefined && first.anchor.kind === AnchorKind.TESTID
28
28
  ? first.anchor.value
29
29
  : undefined;
30
- chips.push(start === undefined ? { name: flow.name } : { name: flow.name, start });
30
+ chips.push({
31
+ name: flow.name,
32
+ createdAt: flow.createdAt,
33
+ ...(start === undefined ? {} : { start }),
34
+ });
31
35
  }
32
36
  return chips;
33
37
  }
@@ -1,5 +1,6 @@
1
- import { AnchorKind, type FlowAnchor, type FlowStep, type FlowStepResult, type Predicate } from '@reticlehq/core';
1
+ import { AnchorKind, type FlowAnchor, type FlowReplayResult, type FlowStep, type FlowStepResult, type Predicate } from '@reticlehq/core';
2
2
  import type { FlowReplaySession, Sleep } from './flow-replay-types.js';
3
+ import { type Reresolved } from './flow-anchor.js';
3
4
  export declare function runRoleStep(session: FlowReplaySession, step: FlowStep, index: number, anchor: Extract<FlowAnchor, {
4
5
  kind: typeof AnchorKind.ROLE;
5
6
  }>, confirmDangerous: boolean, sleep: Sleep): Promise<FlowStepResult>;
@@ -19,8 +20,11 @@ field?: string,
19
20
  * found, and the retry below is worthless without it: re-dispatching the SAME dead ref fails
20
21
  * identically. What changed between the two attempts is the DOM, so the locator has to be run
21
22
  * against it again.
23
+ *
24
+ * It reports WHAT it found, not just a ref: see `Reresolved`. A re-render that turns one match into
25
+ * three is a drift, and the caller is the only layer that can say so with the right anchor in hand.
22
26
  */
23
- reresolve?: () => Promise<string | undefined>): Promise<FlowStepResult>;
27
+ reresolve?: () => Promise<Reresolved | undefined>): Promise<FlowStepResult>;
24
28
  /** Run one component-anchored step: re-resolve via QUERY by:'component', ACT on the live ref, else drift. */
25
29
  export declare function runComponentStep(session: FlowReplaySession, step: FlowStep, index: number, anchor: Extract<FlowAnchor, {
26
30
  kind: typeof AnchorKind.COMPONENT;
@@ -55,6 +59,20 @@ export declare function anchorQueryArgs(anchor: FlowAnchor): Record<string, unkn
55
59
  * flow's own `requires` holds. No tool writes `requires`, so the reset's hint hands over this line.
56
60
  */
57
61
  export declare function anchorPrecondition(anchor: FlowAnchor): Predicate | undefined;
62
+ /**
63
+ * The honest answer when a step's target kept going stale.
64
+ *
65
+ * Replay looks the element up again and goes on while that gets it further, so a step still stale
66
+ * after that is one the page re-rendered around faster than it could be targeted. Reporting it as
67
+ * `error` with the browser's own sentence says "your app broke", which is a claim nothing observed
68
+ * and sends a reader into product code that is fine. It is the same bucket as a document that went
69
+ * away: the steps before it are real and are attached, the step that could not be acted on is among
70
+ * them with its index, and nothing after it ran.
71
+ *
72
+ * Left alone, so the ordinary verdict stands, when anything else is in the run: a drifted anchor is
73
+ * drift whatever else happened, and a failure that is not staleness is a failure.
74
+ */
75
+ export declare function staleTargetResult(name: string, steps: readonly FlowStepResult[]): FlowReplayResult | undefined;
58
76
  /**
59
77
  * Run an act_sequence step: resolve every sub-step's OWN anchor, then dispatch the whole thing as one
60
78
  * ACT_SEQUENCE — the same shape `replayProgram` already uses, so a recorded sequence and a saved one
@@ -7,13 +7,13 @@
7
7
  * or a step's events land in the wrong window depending on how it was addressed.
8
8
  */
9
9
  import { expectedElementTestid } from '@reticlehq/core';
10
- import { AnchorKind, DEGRADED_ANCHOR_ROLE, DriftReason, PredicateKind, QueryBy, ReticleCommand, } from '@reticlehq/core';
10
+ import { AnchorKind, DEGRADED_ANCHOR_ROLE, DriftReason, PredicateKind, QueryBy, ReplayStatus, ReticleCommand, } from '@reticlehq/core';
11
11
  import { ReticleTool } from '@reticlehq/core';
12
12
  import { replayActionArgs } from './replay.js';
13
13
  import { isStaleRefError } from '../../surface/tools/act/act-sequence-retry.js';
14
14
  import { waitForReaction } from '../../surface/tools/act/react-grace.js';
15
15
  import { anchorFieldName } from './flows.js';
16
- import { anchorLabel, componentLabel, componentQueryArgs, expectElementDrift, resolveQuery, testidDrift, } from './flow-anchor.js';
16
+ import { ambiguityDrift, refFor, anchorLabel, componentLabel, componentQueryArgs, expectElementDrift, reresolved, resolveQuery, testidDrift, } from './flow-anchor.js';
17
17
  import { nearestRoleName } from './role-anchor-nearest.js';
18
18
  import { roleDriftReason } from '../../judgement/outcome/role-drift-reason.js';
19
19
  /** Query args for a NAMED role anchor — the handle that identifies an instance, not a JSX site. */
@@ -63,7 +63,25 @@ async function nearestRoleNameOnPage(session, anchor) {
63
63
  export async function runRoleStep(session, step, index, anchor, confirmDangerous, sleep) {
64
64
  const label = `${anchor.role} "${String(anchor.name)}"`;
65
65
  const { refs } = await resolveQuery(session, roleQueryArgs(anchor), sleep);
66
- const ref = refs[0];
66
+ /*
67
+ * More than one match is DRIFT, not a guess. This path took `refs[0]`, dispatched, and returned
68
+ * `ok: true`, so a flow recorded against "View result" on row 3 of a table replayed by clicking row
69
+ * 1 and PASSED whenever every row shared the consequence (the same GET returning 200). The verdict
70
+ * reads `drift` and `ok` and nothing else, so a green here is indistinguishable from a replay that
71
+ * did what it did before.
72
+ *
73
+ * A role+name anchor is MORE likely to land here than a testid is: `button "View result"` is
74
+ * user-visible text, and a table repeats it by nature, so several matches is the expected shape of
75
+ * a recorded row action rather than an accident.
76
+ *
77
+ * The check itself lives in `ambiguityDrift`, shared with the testid and component runners: written
78
+ * per-runner, this rule got applied to two anchor kinds and forgotten for the third.
79
+ */
80
+ const ambiguous = ambiguityDrift(anchor, refs);
81
+ if (ambiguous !== null) {
82
+ return { step: index, tool: step.tool, anchor: label, ok: false, drift: ambiguous };
83
+ }
84
+ const ref = refFor(anchor, refs);
67
85
  if (ref === undefined) {
68
86
  // `nearest` used to be the literal null, so heal answered "no nearest match cleared the
69
87
  // confidence floor" for EVERY role-anchored drift — a structural limit reported as a judgement
@@ -86,8 +104,15 @@ export async function runRoleStep(session, step, index, anchor, confirmDangerous
86
104
  };
87
105
  }
88
106
  return await actOnResolvedRef(session, step, index, label, ref, confirmDangerous, anchorFieldName(anchor),
89
- // The retry's whole point: run the SAME locator against the DOM as it is now.
90
- async () => (await resolveQuery(session, roleQueryArgs(anchor), sleep)).refs[0]);
107
+ /*
108
+ * The retry's whole point: run the SAME locator against the DOM as it is now.
109
+ *
110
+ * It reports WHAT it found, not just a ref. A re-render can turn an unambiguous anchor into an
111
+ * ambiguous one between the two dispatches, and dispatching at the first of the new matches would
112
+ * re-make the guess the check above exists to refuse — one layer deeper, where the caller cannot
113
+ * see it. `reresolved` is the same rule as that check, so the two cannot disagree.
114
+ */
115
+ async () => reresolved(anchor, (await resolveQuery(session, roleQueryArgs(anchor), sleep)).refs));
91
116
  }
92
117
  /**
93
118
  * `assertActionAllowed`'s refusal — "potentially destructive action blocked; retry with
@@ -131,6 +156,9 @@ field,
131
156
  * found, and the retry below is worthless without it: re-dispatching the SAME dead ref fails
132
157
  * identically. What changed between the two attempts is the DOM, so the locator has to be run
133
158
  * against it again.
159
+ *
160
+ * It reports WHAT it found, not just a ref: see `Reresolved`. A re-render that turns one match into
161
+ * three is a drift, and the caller is the only layer that can say so with the right anchor in hand.
134
162
  */
135
163
  reresolve) {
136
164
  const dispatch = async (at) => {
@@ -167,8 +195,24 @@ reresolve) {
167
195
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
168
196
  });
169
197
  const fresh = await reresolve();
198
+ /*
199
+ * The re-render turned one match into SEVERAL, so there is nothing to dispatch at and the answer
200
+ * is a drift — the same `anchor_ambiguous` the first resolution would have given, reported for the
201
+ * same reason.
202
+ *
203
+ * The staleness this retry exists to absorb is a different diagnosis with a different fix, and
204
+ * this used to be the only one reported: the step came back as `{ok: false, error: "ref 'e-orig'
205
+ * no longer resolves to an element"}` — a sentence about an element that is GONE, sent for an
206
+ * element that is now three. An agent reading it goes hunting for a rename, or re-records the
207
+ * flow, when the actual fix is a narrower anchor. Telling the two apart is what `Reresolved` is
208
+ * for, and the reason `ANCHOR_AMBIGUOUS` exists as its own kind rather than folding into "not
209
+ * found".
210
+ */
211
+ if (fresh !== undefined && 'drift' in fresh) {
212
+ return { step: index, tool: step.tool, anchor: label, ok: false, drift: fresh.drift };
213
+ }
170
214
  if (fresh !== undefined)
171
- act = await dispatch(fresh);
215
+ act = await dispatch(fresh.ref);
172
216
  }
173
217
  const result = { step: index, tool: step.tool, anchor: label, ok: act.ok };
174
218
  if (!act.ok)
@@ -195,10 +239,28 @@ export async function runComponentStep(session, step, index, anchor, confirmDang
195
239
  },
196
240
  };
197
241
  }
242
+ /*
243
+ * Several matches is DRIFT here too, and this runner is where the rule was MISSING.
244
+ *
245
+ * The other two runners check it (through `ambiguityDrift`); this one took `refs[0]`, dispatched and
246
+ * returned `ok: true`. `by: QueryBy.COMPONENT` matches by component NAME, and a component rendered
247
+ * once per row — a table's action button, a card's menu — matches as many times as there are rows.
248
+ * So the guessed-green shape is not an edge case for this anchor kind, it is its ordinary one, and
249
+ * it is the exact defect #1227 describes: a click on the wrong element reported as a pass because
250
+ * the verdict reads `drift` and `ok` and never reads which element was hit.
251
+ */
252
+ const ambiguous = ambiguityDrift(anchor, refs);
253
+ if (ambiguous !== null) {
254
+ return { step: index, tool: step.tool, anchor: label, ok: false, drift: ambiguous };
255
+ }
198
256
  const ref = refs[0] ?? '';
199
257
  // Shared with the role path so both get the same action window AND the same stale-ref retry —
200
258
  // this used to dispatch inline, which is how one anchor kind could gain a fix the other lacked.
201
- return await actOnResolvedRef(session, step, index, label, ref, confirmDangerous, anchorFieldName(anchor), async () => (await resolveQuery(session, componentQueryArgs(anchor), sleep)).refs[0]);
259
+ return await actOnResolvedRef(session, step, index, label, ref, confirmDangerous, anchorFieldName(anchor),
260
+ // Same rule one layer deeper, same helper: a re-render that turns one match into three must not
261
+ // be dispatched at either, and it must say so as a drift rather than as a dead ref. This callback
262
+ // used to return `refs[0]` unconditionally.
263
+ async () => reresolved(anchor, (await resolveQuery(session, componentQueryArgs(anchor), sleep)).refs));
202
264
  }
203
265
  /**
204
266
  * DEGRADED_ANCHOR_ROLE is a MARKER ("no anchor could be determined"), never a locator.
@@ -268,6 +330,63 @@ export function anchorPrecondition(anchor) {
268
330
  }
269
331
  return undefined;
270
332
  }
333
+ /** What is added to a stale-ref error when it cannot be traced to one sub-step of the sequence. */
334
+ const STALE_SUB_STEP_UNKNOWN = ' (which sub-step that was could not be told, so the ones before it may have run)';
335
+ /** Where a ref that stayed stale sat in the sequence, and that every sub-step before it ran. */
336
+ const staleSubStepPosition = (at, total) => ` (sub-step ${String(at)} of ${String(total)}; ${String(at)} before it ran)`;
337
+ /**
338
+ * Why a step whose target kept going stale is unverifiable, as the replay result tells a reader.
339
+ * `step` is the index of the step that could not be acted on.
340
+ */
341
+ const staleTargetReason = (step) => `step ${String(step)} could not be acted on: its target kept going stale, because the ` +
342
+ `page re-rendered between finding the element and using it, and the replay looked it up ` +
343
+ `again for as long as that got it further. The steps before it are reported and nothing ` +
344
+ `after it ran. Nothing here says the app failed. If an earlier step makes the page ` +
345
+ `re-render, record that step's consequence with \`expect\` so the replay waits for the new ` +
346
+ `render before it moves on.`;
347
+ /**
348
+ * The sub-step, among live[from, to), whose ref the page said it could not find.
349
+ *
350
+ * The page echoes the ref in "ref 'x' no longer resolves to an element". A ref it shortened, or one
351
+ * that is not among these, gives no answer. Neither does a ref that two sub-steps share: the first of
352
+ * them may already have run, and may be what re-rendered the page, which makes the later one the stale
353
+ * one, and nothing in the message says so. The caller must not guess which actions already ran.
354
+ */
355
+ function staleSubStep(error, live, from, to) {
356
+ const named = /ref '([^']*)'/.exec(error)?.[1];
357
+ if (named === undefined)
358
+ return undefined;
359
+ const sharing = live.flatMap((one, at) => at >= from && at < to && one.ref === named ? [at] : []);
360
+ const [only] = sharing;
361
+ return 1 === sharing.length ? only : undefined;
362
+ }
363
+ /**
364
+ * The honest answer when a step's target kept going stale.
365
+ *
366
+ * Replay looks the element up again and goes on while that gets it further, so a step still stale
367
+ * after that is one the page re-rendered around faster than it could be targeted. Reporting it as
368
+ * `error` with the browser's own sentence says "your app broke", which is a claim nothing observed
369
+ * and sends a reader into product code that is fine. It is the same bucket as a document that went
370
+ * away: the steps before it are real and are attached, the step that could not be acted on is among
371
+ * them with its index, and nothing after it ran.
372
+ *
373
+ * Left alone, so the ordinary verdict stands, when anything else is in the run: a drifted anchor is
374
+ * drift whatever else happened, and a failure that is not staleness is a failure.
375
+ */
376
+ export function staleTargetResult(name, steps) {
377
+ if (steps.some((step) => step.drift !== undefined))
378
+ return undefined;
379
+ const failed = steps.filter((step) => !step.ok);
380
+ const [only] = failed;
381
+ if (failed.length !== 1 || only === undefined || !isStaleRefError(only.error))
382
+ return undefined;
383
+ return {
384
+ name,
385
+ status: ReplayStatus.UNVERIFIABLE,
386
+ steps: [...steps],
387
+ unverifiable: { reason: staleTargetReason(only.step) },
388
+ };
389
+ }
271
390
  /**
272
391
  * Run an act_sequence step: resolve every sub-step's OWN anchor, then dispatch the whole thing as one
273
392
  * ACT_SEQUENCE — the same shape `replayProgram` already uses, so a recorded sequence and a saved one
@@ -281,37 +400,62 @@ export function anchorPrecondition(anchor) {
281
400
  export async function runSequenceStep(session, step, index, subs, confirmDangerous, sleep,
282
401
  /** Testids whose presence is deliberately NOT asserted — the LLM-output case. */
283
402
  dynamic = new Set()) {
284
- const live = [];
285
- for (const [subIndex, sub] of subs.entries()) {
403
+ /**
404
+ * Find one sub-step's element as the page is NOW. Either the live step to dispatch, or the step
405
+ * result that ends the run: a degraded anchor, or one that is no longer on the page.
406
+ *
407
+ * `afterRerender` is the second look, taken because the page re-rendered under a sub-step. An anchor
408
+ * that now matches several elements is drift there, as it is for a single step's retry: the first of
409
+ * the new matches may be an element the recording did not mean, and acting on it would report a pass
410
+ * for a click nobody can attribute. The first look keeps taking the first match.
411
+ */
412
+ const resolveSub = async (sub, subIndex, afterRerender = false) => {
286
413
  const label = `${anchorLabel(sub.anchor)} (sub-step ${String(subIndex)})`;
287
414
  const queryArgs = anchorQueryArgs(sub.anchor);
288
415
  if (null === queryArgs)
289
- return degradedStepResult(step, index, label);
416
+ return { stop: degradedStepResult(step, index, label) };
290
417
  const { refs, hint } = await resolveQuery(session, queryArgs, sleep);
291
- const ref = refs[0];
418
+ const ambiguous = afterRerender ? ambiguityDrift(sub.anchor, refs) : null;
419
+ if (ambiguous !== null) {
420
+ return { stop: { step: index, tool: step.tool, anchor: label, ok: false, drift: ambiguous } };
421
+ }
422
+ const ref = afterRerender ? refFor(sub.anchor, refs) : refs[0];
292
423
  if (ref === undefined) {
293
424
  return {
294
- step: index,
295
- tool: step.tool,
296
- anchor: label,
297
- ok: false,
298
- drift: sub.anchor.kind === AnchorKind.TESTID
299
- ? testidDrift(sub.anchor.value, hint)
300
- : {
301
- reasonKind: DriftReason.COMPONENT_NOT_FOUND,
302
- reason: `anchor ${label} not found`,
303
- anchor: label,
304
- nearest: null,
305
- },
425
+ stop: {
426
+ step: index,
427
+ tool: step.tool,
428
+ anchor: label,
429
+ ok: false,
430
+ drift: sub.anchor.kind === AnchorKind.TESTID
431
+ ? testidDrift(sub.anchor.value, hint)
432
+ : {
433
+ reasonKind: DriftReason.COMPONENT_NOT_FOUND,
434
+ reason: `anchor ${label} not found`,
435
+ anchor: label,
436
+ nearest: null,
437
+ },
438
+ },
306
439
  };
307
440
  }
308
- live.push({
309
- ref,
310
- action: sub.action ?? '',
311
- // Each sub-step carries its OWN anchor, so each gets its own field name. A sequence that ends
312
- // in a login is the shape this was reported on, and the sub-step is where the fill lives.
313
- args: replayActionArgs(sub.args, confirmDangerous, anchorFieldName(sub.anchor)),
314
- });
441
+ return {
442
+ live: {
443
+ ref,
444
+ action: sub.action ?? '',
445
+ // Each sub-step carries its OWN anchor, so each gets its own field name. A sequence that ends
446
+ // in a login is the shape this was reported on, and the sub-step is where the fill lives.
447
+ args: replayActionArgs(sub.args, confirmDangerous, anchorFieldName(sub.anchor)),
448
+ },
449
+ };
450
+ };
451
+ // Every anchor is checked before anything runs, so a sub-step that has drifted away is reported as
452
+ // drift and not discovered half way through a sequence that has already acted.
453
+ const live = [];
454
+ for (const [subIndex, sub] of subs.entries()) {
455
+ const resolved = await resolveSub(sub, subIndex);
456
+ if ('stop' in resolved)
457
+ return resolved.stop;
458
+ live.push(resolved.live);
315
459
  }
316
460
  const result = {
317
461
  step: index,
@@ -320,20 +464,71 @@ dynamic = new Set()) {
320
464
  ok: true,
321
465
  };
322
466
  /** One dispatch through the SAME command either way, so batched and walked replay identically. */
323
- const dispatch = async (steps) => {
467
+ const send = async (steps) => {
324
468
  session.beginAction?.(ReticleTool.FLOW_REPLAY, { steps: steps.length });
325
- let act;
326
469
  try {
327
- act = await session.command(ReticleCommand.ACT_SEQUENCE, { steps });
470
+ return await session.command(ReticleCommand.ACT_SEQUENCE, { steps });
328
471
  }
329
472
  finally {
330
473
  session.finishAction?.();
331
474
  }
332
- if (act.ok)
333
- return true;
334
- result.ok = false;
335
- result.error = replayDestructiveActionHint(act.error ?? 'command failed');
336
- return false;
475
+ };
476
+ /**
477
+ * Run sub-steps [from, to), and survive the page re-rendering under them.
478
+ *
479
+ * The refs were resolved before anything ran, and the first action of a sequence is allowed to
480
+ * re-render the page, so by the time a later sub-step is reached its ref may be dead. The same
481
+ * anchor is almost always still there under a new ref; what was missing is a second look.
482
+ *
483
+ * The page names the ref it could not find, which says WHICH sub-step failed and so which ones
484
+ * already ran, as long as no other sub-step holds the same ref. Only the rest is resolved again and
485
+ * sent, so nothing acts twice: starting over is what turns one click into two. A stale ref is the
486
+ * only failure retried, and the retry goes on only while it gets further. A sub-step that is stale
487
+ * again at the same point is a target that does not hold still, and the run says so, with how far
488
+ * it got.
489
+ */
490
+ const dispatchFrom = async (from, to) => {
491
+ let start = from;
492
+ let staleBefore = -1;
493
+ for (;;) {
494
+ const act = await send(live.slice(start, to));
495
+ if (act.ok)
496
+ return true;
497
+ const error = act.error ?? 'command failed';
498
+ if (!isStaleRefError(error)) {
499
+ result.ok = false;
500
+ result.error = replayDestructiveActionHint(error);
501
+ return false;
502
+ }
503
+ const staleAt = staleSubStep(error, live, start, to);
504
+ if (staleAt === undefined) {
505
+ result.ok = false;
506
+ result.error = error + STALE_SUB_STEP_UNKNOWN;
507
+ return false;
508
+ }
509
+ if (staleAt <= staleBefore) {
510
+ result.ok = false;
511
+ result.error = error + staleSubStepPosition(staleAt, subs.length);
512
+ return false;
513
+ }
514
+ staleBefore = staleAt;
515
+ await waitForReaction(session, 0, STALE_REF_GRACE_MS, { sleep });
516
+ for (let at = staleAt; at < to; at += 1) {
517
+ const sub = subs[at];
518
+ if (sub === undefined)
519
+ continue;
520
+ const resolved = await resolveSub(sub, at, true);
521
+ if ('stop' in resolved) {
522
+ result.ok = false;
523
+ result.anchor = resolved.stop.anchor;
524
+ if (resolved.stop.drift !== undefined)
525
+ result.drift = resolved.stop.drift;
526
+ return false;
527
+ }
528
+ live[at] = resolved.live;
529
+ }
530
+ start = staleAt;
531
+ }
337
532
  };
338
533
  /** The sub-step's own expectation, unless its testid is deliberately unasserted. */
339
534
  const declaredBy = (sub) => {
@@ -368,14 +563,13 @@ dynamic = new Set()) {
368
563
  */
369
564
  const declaring = subs.some((sub) => declaredBy(sub) !== undefined);
370
565
  if (!declaring) {
371
- await dispatch(live);
566
+ await dispatchFrom(0, live.length);
372
567
  return result;
373
568
  }
374
569
  for (const [subIndex, sub] of subs.entries()) {
375
- const one = live[subIndex];
376
- if (one === undefined)
570
+ if (live[subIndex] === undefined)
377
571
  continue;
378
- if (!(await dispatch([one])))
572
+ if (!(await dispatchFrom(subIndex, subIndex + 1)))
379
573
  return result;
380
574
  const testid = declaredBy(sub);
381
575
  if (testid !== undefined && !(await assertDeclared(testid)))
@@ -24,9 +24,9 @@
24
24
  * returned unchanged.
25
25
  */
26
26
  import { FlowStore } from './flows.js';
27
- import type { ProjectId } from '@reticlehq/core';
27
+ import type { ProjectTarget } from '../../memory/project/session-root.js';
28
28
  import type { ToolDeps } from '../../surface/tools/tool-kit.js';
29
- export declare function flowsForSession(deps: ToolDeps, projectId: ProjectId | undefined): {
29
+ export declare function flowsForSession(deps: ToolDeps, target: ProjectTarget): {
30
30
  flows: FlowStore;
31
31
  root: string;
32
32
  };
@@ -24,8 +24,8 @@
24
24
  * returned unchanged.
25
25
  */
26
26
  import { FlowStore } from './flows.js';
27
- export function flowsForSession(deps, projectId) {
28
- const resolved = deps.artifactRootFor?.(projectId);
27
+ export function flowsForSession(deps, target) {
28
+ const resolved = deps.artifactRootFor?.(target.projectId, target.origin);
29
29
  if (resolved === undefined || resolved.root === deps.reticleRoot) {
30
30
  return { flows: deps.flows, root: deps.reticleRoot };
31
31
  }