@reticlehq/server 3.3.0 → 3.4.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 (299) hide show
  1. package/LICENSE-ENTERPRISE +1 -1
  2. package/README.md +3 -3
  3. package/SKILL.md +12 -14
  4. package/dist/chromium-launch-options.d.ts +4 -3
  5. package/dist/chromium-launch-options.js +6 -2
  6. package/dist/command/cli/adhoc-suite.d.ts +22 -0
  7. package/dist/command/cli/adhoc-suite.js +90 -19
  8. package/dist/command/cli/adhoc-verdict.d.ts +35 -0
  9. package/dist/command/cli/adhoc-verdict.js +90 -3
  10. package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
  11. package/dist/command/cli/answers/gate-hook-message.js +6 -0
  12. package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
  13. package/dist/command/cli/auth/cloud-keystore.js +0 -3
  14. package/dist/command/cli/auth/cloud-session.d.ts +4 -0
  15. package/dist/command/cli/auth/cloud-session.js +2 -0
  16. package/dist/command/cli/cli-doctor.js +7 -5
  17. package/dist/command/cli/cli-flow-commands.d.ts +3 -6
  18. package/dist/command/cli/cli-flow-commands.js +67 -4
  19. package/dist/command/cli/cli-parse.d.ts +8 -1
  20. package/dist/command/cli/cli-parse.js +21 -3
  21. package/dist/command/cli/cli-update-commands.js +8 -1
  22. package/dist/command/cli/cli-verify.d.ts +24 -3
  23. package/dist/command/cli/cli-verify.js +54 -11
  24. package/dist/command/cli/cloud-cli.js +5 -2
  25. package/dist/command/cli/cloud-kit.js +3 -5
  26. package/dist/command/cli/cloud-login.js +21 -3
  27. package/dist/command/cli/demo/demo-page.js +6 -3
  28. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
  29. package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
  30. package/dist/command/cli/launch/cli-launch.d.ts +8 -0
  31. package/dist/command/cli/launch/cli-launch.js +21 -9
  32. package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
  33. package/dist/command/cli/launch/sdk-version-match.js +206 -0
  34. package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
  35. package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
  36. package/dist/command/cli/ports/resolve/cli-port.js +125 -15
  37. package/dist/command/cli/setup-mcp-cli.js +13 -13
  38. package/dist/command/cli/tutorial.d.ts +2 -2
  39. package/dist/command/cli/tutorial.js +21 -7
  40. package/dist/command/cli.js +62 -10
  41. package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
  42. package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
  43. package/dist/command/daemon/daemon-resolve.d.ts +9 -0
  44. package/dist/command/daemon/daemon-resolve.js +1 -1
  45. package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
  46. package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
  47. package/dist/command/dev/stale-issue-guard.d.ts +3 -4
  48. package/dist/command/dev/stale-issue-guard.js +8 -8
  49. package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
  50. package/dist/command/setup/bringup/connect-lease.js +38 -0
  51. package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
  52. package/dist/command/setup/bringup/dev-server-files.js +131 -0
  53. package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
  54. package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
  55. package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
  56. package/dist/command/setup/bringup/ensure-daemon.js +135 -16
  57. package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
  58. package/dist/command/setup/bringup/static-page-server.js +151 -0
  59. package/dist/command/setup/desktop-shape.d.ts +4 -0
  60. package/dist/command/setup/desktop-shape.js +18 -2
  61. package/dist/command/setup/init/init-port.d.ts +15 -0
  62. package/dist/command/setup/init/init-port.js +44 -0
  63. package/dist/command/setup/init/init-runtime.d.ts +1 -1
  64. package/dist/command/setup/init/init-runtime.js +39 -2
  65. package/dist/command/setup/node-effects.d.ts +27 -14
  66. package/dist/command/setup/node-effects.js +80 -44
  67. package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
  68. package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
  69. package/dist/command/setup/probe/page-probe.d.ts +13 -2
  70. package/dist/command/setup/probe/page-probe.js +18 -3
  71. package/dist/command/setup/run-setup.d.ts +26 -1
  72. package/dist/command/setup/run-setup.js +96 -13
  73. package/dist/command/setup/session-pick.d.ts +13 -0
  74. package/dist/command/setup/session-pick.js +20 -0
  75. package/dist/command/setup/setup-command.d.ts +2 -14
  76. package/dist/command/setup/setup-command.js +105 -31
  77. package/dist/command/setup/setup-mcp.js +3 -3
  78. package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
  79. package/dist/command/setup/terminal/wait-progress.js +17 -0
  80. package/dist/features/crawl/crawl-empty.js +1 -1
  81. package/dist/features/crawl/crawl-tools.d.ts +6 -0
  82. package/dist/features/crawl/crawl-tools.js +57 -0
  83. package/dist/features/crawl/crawl.js +171 -157
  84. package/dist/features/exhaust/app-coverage.d.ts +33 -0
  85. package/dist/features/exhaust/app-coverage.js +31 -0
  86. package/dist/features/exhaust/code-coverage.d.ts +47 -0
  87. package/dist/features/exhaust/code-coverage.js +77 -0
  88. package/dist/features/exhaust/exhaust-run.d.ts +33 -0
  89. package/dist/features/exhaust/exhaust-run.js +82 -0
  90. package/dist/features/exhaust/explorer.d.ts +103 -0
  91. package/dist/features/exhaust/explorer.js +195 -0
  92. package/dist/features/exhaust/failure-branches.d.ts +33 -0
  93. package/dist/features/exhaust/failure-branches.js +92 -0
  94. package/dist/features/exhaust/ledger.d.ts +202 -0
  95. package/dist/features/exhaust/ledger.js +252 -0
  96. package/dist/features/exhaust/session-fold.d.ts +31 -0
  97. package/dist/features/exhaust/session-fold.js +79 -0
  98. package/dist/features/exhaust/session-port.d.ts +36 -0
  99. package/dist/features/exhaust/session-port.js +102 -0
  100. package/dist/features/harness/drive-report.d.ts +0 -9
  101. package/dist/features/harness/drive-report.js +18 -0
  102. package/dist/features/harness/driver.js +4 -7
  103. package/dist/features/harness/fill-values.d.ts +6 -0
  104. package/dist/features/harness/fill-values.js +3 -0
  105. package/dist/features/harness/jev-driver.js +30 -13
  106. package/dist/features/harness/openai-driver.js +4 -7
  107. package/dist/features/harness/platform-config.js +5 -6
  108. package/dist/index.d.ts +9 -0
  109. package/dist/index.js +40 -11
  110. package/dist/judgement/domain/domain-model.js +3 -1
  111. package/dist/judgement/oracles/self-instrument.js +4 -2
  112. package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
  113. package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
  114. package/dist/judgement/runs/verification-sync.d.ts +3 -1
  115. package/dist/judgement/runs/verification-sync.js +1 -1
  116. package/dist/language/flows/change/affected.d.ts +9 -0
  117. package/dist/language/flows/change/affected.js +35 -0
  118. package/dist/language/flows/change/gate.d.ts +20 -0
  119. package/dist/language/flows/change/gate.js +12 -1
  120. package/dist/language/flows/flow-author.d.ts +7 -0
  121. package/dist/language/flows/flow-author.js +19 -0
  122. package/dist/language/flows/flow-expect-grammar.js +1 -1
  123. package/dist/language/flows/flow-journey.d.ts +54 -0
  124. package/dist/language/flows/flow-journey.js +119 -0
  125. package/dist/language/flows/flow-replay-run.d.ts +2 -3
  126. package/dist/language/flows/flow-replay-run.js +13 -47
  127. package/dist/language/flows/flow-replay.d.ts +22 -8
  128. package/dist/language/flows/flow-replay.js +55 -9
  129. package/dist/language/flows/flow-result.d.ts +2 -0
  130. package/dist/language/flows/flow-result.js +25 -0
  131. package/dist/language/flows/flow-tools.js +17 -5
  132. package/dist/language/flows/flows.d.ts +10 -1
  133. package/dist/language/flows/flows.js +90 -14
  134. package/dist/language/flows/recording/replay-halt.js +3 -1
  135. package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
  136. package/dist/language/flows/recording/tape/recordings.js +55 -1
  137. package/dist/language/flows/replay.d.ts +19 -0
  138. package/dist/language/flows/replay.js +42 -6
  139. package/dist/language/flows/verify-change-tools.js +5 -2
  140. package/dist/launch-chromium.d.ts +80 -0
  141. package/dist/launch-chromium.js +218 -0
  142. package/dist/memory/cloud/cloud-config.d.ts +11 -2
  143. package/dist/memory/cloud/cloud-config.js +47 -7
  144. package/dist/memory/cloud/cloud-sync.d.ts +4 -1
  145. package/dist/memory/cloud/cloud-sync.js +6 -9
  146. package/dist/memory/cloud/harness-offer.js +5 -6
  147. package/dist/memory/cloud/harness-switch.js +5 -6
  148. package/dist/memory/cloud/sync-cycle.d.ts +68 -3
  149. package/dist/memory/cloud/sync-cycle.js +298 -55
  150. package/dist/memory/cloud/sync-daemon.js +24 -20
  151. package/dist/memory/cloud/sync-disk.js +16 -2
  152. package/dist/memory/intent/intent-shard.d.ts +10 -32
  153. package/dist/memory/intent/intent-shard.js +0 -10
  154. package/dist/memory/journal/drive-flow.d.ts +18 -13
  155. package/dist/memory/journal/drive-flow.js +45 -37
  156. package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
  157. package/dist/memory/journal/session-end.d.ts +9 -1
  158. package/dist/memory/journal/session-end.js +24 -12
  159. package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
  160. package/dist/memory/project/dir/reticle-dir.js +1 -0
  161. package/dist/memory/project/project-tools.js +3 -1
  162. package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
  163. package/dist/portal/bridge/auth-failure-reason.js +4 -2
  164. package/dist/portal/bridge/bridge.d.ts +8 -0
  165. package/dist/portal/bridge/bridge.js +43 -2
  166. package/dist/portal/input/js-coverage.d.ts +25 -0
  167. package/dist/portal/input/js-coverage.js +30 -0
  168. package/dist/portal/input/real-input.d.ts +11 -0
  169. package/dist/portal/input/real-input.js +29 -4
  170. package/dist/portal/pool/playwright-launcher.d.ts +6 -0
  171. package/dist/portal/pool/playwright-launcher.js +20 -13
  172. package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
  173. package/dist/portal/session/dev-server/dev-command.js +30 -12
  174. package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
  175. package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
  176. package/dist/portal/session/dev-server/route-status-probe.js +57 -0
  177. package/dist/portal/session/facts/observed-state.d.ts +11 -0
  178. package/dist/portal/session/facts/observed-state.js +34 -0
  179. package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
  180. package/dist/portal/session/no-session-diagnosis.js +62 -5
  181. package/dist/portal/session/no-session-next-action.d.ts +13 -0
  182. package/dist/portal/session/no-session-next-action.js +48 -9
  183. package/dist/portal/session/no-session-watch.d.ts +18 -0
  184. package/dist/portal/session/no-session-watch.js +88 -2
  185. package/dist/portal/session/session-health.js +3 -2
  186. package/dist/portal/session/session-manager.d.ts +20 -6
  187. package/dist/portal/session/session-manager.js +19 -3
  188. package/dist/portal/session/session-reaper.d.ts +28 -0
  189. package/dist/portal/session/session-reaper.js +49 -1
  190. package/dist/portal/session/session.d.ts +5 -0
  191. package/dist/portal/session/session.js +15 -19
  192. package/dist/surface/http-server.d.ts +2 -1
  193. package/dist/surface/http-server.js +4 -2
  194. package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
  195. package/dist/surface/mcp/mcp-proxy.js +2 -2
  196. package/dist/surface/mcp/mcp.js +4 -2
  197. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
  198. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
  199. package/dist/surface/mcp/server-instructions.js +9 -7
  200. package/dist/surface/tools/act/after-match.d.ts +17 -0
  201. package/dist/surface/tools/act/after-match.js +30 -0
  202. package/dist/surface/tools/act/durable.d.ts +69 -0
  203. package/dist/surface/tools/act/durable.js +139 -0
  204. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
  205. package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
  206. package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
  207. package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
  208. package/dist/surface/tools/act/proved-controls.d.ts +9 -0
  209. package/dist/surface/tools/act/proved-controls.js +11 -0
  210. package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
  211. package/dist/surface/tools/act/sequence-grade.js +32 -1
  212. package/dist/surface/tools/act-capsule.d.ts +7 -0
  213. package/dist/surface/tools/act-capsule.js +46 -1
  214. package/dist/surface/tools/act-sequence-tool.js +25 -8
  215. package/dist/surface/tools/act-tools.js +26 -50
  216. package/dist/surface/tools/assert/assert-grade.js +12 -1
  217. package/dist/surface/tools/assert/assert-verdict.js +2 -2
  218. package/dist/surface/tools/browser-tools.js +14 -2
  219. package/dist/surface/tools/coverage-tools.js +27 -1
  220. package/dist/surface/tools/error-recovery.js +4 -4
  221. package/dist/surface/tools/feedback-invite.js +6 -4
  222. package/dist/surface/tools/harness-explore.d.ts +3 -2
  223. package/dist/surface/tools/harness-explore.js +7 -6
  224. package/dist/surface/tools/lease-tools.js +15 -6
  225. package/dist/surface/tools/live-call-text.js +4 -1
  226. package/dist/surface/tools/observe-tools.js +9 -1
  227. package/dist/surface/tools/tools.js +4 -0
  228. package/dist/telemetry/argument-shape.d.ts +0 -2
  229. package/dist/telemetry/argument-shape.js +0 -4
  230. package/dist/telemetry/error-class.d.ts +2 -3
  231. package/dist/telemetry/error-class.js +2 -3
  232. package/dist/telemetry/harness-drive.d.ts +0 -2
  233. package/dist/telemetry/harness-drive.js +0 -4
  234. package/dist/wire-journal.d.ts +3 -0
  235. package/dist/wire-journal.js +4 -0
  236. package/dist/workspace-packages.d.ts +0 -2
  237. package/dist/workspace-packages.js +1 -9
  238. package/docs/README.md +4 -2
  239. package/docs/actions.mdx +3 -3
  240. package/docs/agent-cheatsheet.md +4 -4
  241. package/docs/architecture.md +6 -6
  242. package/docs/best-practices.mdx +1 -1
  243. package/docs/capabilities.mdx +32 -1
  244. package/docs/cli/cloud.mdx +14 -4
  245. package/docs/cli/doctor.mdx +4 -0
  246. package/docs/cli/feedback.mdx +2 -2
  247. package/docs/cli/gate.mdx +9 -4
  248. package/docs/cli/update.mdx +10 -0
  249. package/docs/cli.mdx +9 -0
  250. package/docs/desktop.mdx +14 -10
  251. package/docs/environment.mdx +87 -0
  252. package/docs/faq.mdx +18 -16
  253. package/docs/first-drive.md +5 -13
  254. package/docs/flows.md +21 -2
  255. package/docs/frameworks.mdx +29 -10
  256. package/docs/getting-started.md +3 -3
  257. package/docs/http-transport.md +6 -3
  258. package/docs/human-control.md +2 -2
  259. package/docs/index.mdx +16 -6
  260. package/docs/install-agentic.mdx +1 -1
  261. package/docs/install-manual.mdx +1 -1
  262. package/docs/integration-patterns.md +3 -3
  263. package/docs/multi-agent-testing.md +1 -1
  264. package/docs/packages/babel-plugin.mdx +1 -1
  265. package/docs/packages/browser.mdx +2 -2
  266. package/docs/packages/react.mdx +2 -2
  267. package/docs/packages/tauri.mdx +1 -1
  268. package/docs/packages/vite-plugin.mdx +2 -2
  269. package/docs/packages.mdx +9 -9
  270. package/docs/predicates.mdx +50 -5
  271. package/docs/quickstart.mdx +2 -2
  272. package/docs/recipes.mdx +2 -2
  273. package/docs/state-management.mdx +2 -2
  274. package/docs/system-map.md +1 -1
  275. package/docs/telemetry.md +1 -1
  276. package/docs/token-efficiency.md +3 -3
  277. package/docs/tools/act-and-wait.mdx +1 -0
  278. package/docs/tools/act-sequence.mdx +1 -1
  279. package/docs/tools/console.mdx +1 -1
  280. package/docs/tools/inspect.mdx +1 -1
  281. package/docs/tools/network.mdx +1 -1
  282. package/docs/tools/query.mdx +1 -1
  283. package/docs/tools/sessions.mdx +1 -1
  284. package/docs/tools/snapshot.mdx +1 -1
  285. package/docs/tools/state.mdx +1 -1
  286. package/docs/tools/wait-for.mdx +1 -1
  287. package/docs/usage.md +7 -6
  288. package/docs/use-cases.mdx +157 -0
  289. package/docs/vs-screenshots.mdx +3 -3
  290. package/docs/what-is-recorded.md +2 -2
  291. package/docs/what-is-reticle.mdx +17 -20
  292. package/docs/why-reticle.mdx +14 -21
  293. package/package.json +10 -9
  294. package/dist/judgement/capsule/minimize.d.ts +0 -12
  295. package/dist/judgement/capsule/minimize.js +0 -22
  296. package/dist/judgement/phenomena/phenomena.d.ts +0 -27
  297. package/dist/judgement/phenomena/phenomena.js +0 -101
  298. package/dist/language/flows/flow-report.d.ts +0 -22
  299. package/dist/language/flows/flow-report.js +0 -106
@@ -17,6 +17,7 @@
17
17
  * would make the daemon wait. `assert` / `wait_for` / `act_and_wait` all spend the caller's
18
18
  * `timeout_ms` (resolve-within.ts); this is the same budget applied to the same wait.
19
19
  */
20
+ import { awaitDocumentSuccessor } from '../../../../portal/session/session-successor.js';
20
21
  /**
21
22
  * The default when the caller gives no `timeout_ms`: long enough for a dev server to serve a page
22
23
  * and the SDK to dial back; short enough to not hang. A caller who knows the app is slower says so.
@@ -69,19 +70,27 @@ export function idsAtTarget(sessions, target) {
69
70
  }
70
71
  return ids;
71
72
  }
72
- function findArrival(sessions, target, scope) {
73
+ function findArrival(sessions, target, scope, currentSession) {
73
74
  let arrived = null;
74
75
  for (const s of sessions.all()) {
75
- if (!samePage(s.url, target))
76
+ // The currently tracked document gets priority because it is the strongest
77
+ // evidence of where this navigation went.
78
+ if (s.id === currentSession.id) {
79
+ if (samePage(s.url, target)) {
80
+ return { sessionId: s.id };
81
+ }
82
+ // Intermediate routes are not necessarily redirects. Keep polling so
83
+ // the document has a chance to reach the requested target.
76
84
  continue;
77
- // The tab we drove is the answer whenever it is there, not a tie broken by scan order.
78
- if (s.id === scope.navigatedId)
79
- return { sessionId: s.id };
85
+ }
86
+ // A different session that was already at the target cannot prove this
87
+ // navigation arrived.
80
88
  if (scope.priorIds.has(s.id))
81
89
  continue;
82
- // Keep looking rather than returning: the driven session may still be ahead of us.
83
- if (null === arrived)
84
- arrived = { sessionId: s.id };
90
+ if (samePage(s.url, target)) {
91
+ if (null === arrived)
92
+ arrived = { sessionId: s.id };
93
+ }
85
94
  }
86
95
  return arrived;
87
96
  }
@@ -96,11 +105,39 @@ const REAL_CLOCK = {
96
105
  export async function awaitArrival(sessions, target, scope, timeoutMs = ARRIVAL_TIMEOUT_MS, clock = REAL_CLOCK) {
97
106
  const deadline = clock.now() + timeoutMs;
98
107
  for (;;) {
99
- const found = findArrival(sessions, target, scope);
108
+ // Keep checking the live session registry for the entire caller-provided
109
+ // budget. This is important because a document can disconnect and reconnect
110
+ // under another id, and the new session may be at the requested target,
111
+ // including on a different origin.
112
+ const found = findArrival(sessions, target, scope, scope.navigatedSession);
100
113
  if (found !== null)
101
114
  return found;
102
115
  if (clock.now() >= deadline)
103
- return null;
116
+ break;
104
117
  await clock.sleep(POLL_MS);
105
118
  }
119
+ // Only after the navigation wait has expired do we use successor detection.
120
+ // At this point it is no longer being used to decide whether the requested
121
+ // navigation arrived. It only helps explain where the replaced document
122
+ // landed when the target was never observed.
123
+ const current = sessions.get(scope.navigatedSession.id);
124
+ if (current !== undefined) {
125
+ if (!samePage(current.url, scope.navigatedFrom) && !samePage(current.url, target)) {
126
+ return {
127
+ sessionId: current.id,
128
+ landedOn: current.url,
129
+ };
130
+ }
131
+ return null;
132
+ }
133
+ const successor = await awaitDocumentSuccessor(sessions, scope.navigatedSession, 0, clock);
134
+ if (successor !== null &&
135
+ !samePage(successor.url, target) &&
136
+ !samePage(successor.url, scope.navigatedFrom)) {
137
+ return {
138
+ sessionId: successor.id,
139
+ landedOn: successor.url,
140
+ };
141
+ }
142
+ return null;
106
143
  }
@@ -1,18 +1,7 @@
1
- /**
2
- * The honest envelope for a navigation nobody can confirm.
3
- *
4
- * `window.location.assign(url)` returns before the page moves, and the SDK that would report on the
5
- * new document is destroyed by the navigation itself. So a successful NAVIGATE means the browser
6
- * accepted the instruction — not that anything arrived. Driven against a dead URL it returned
7
- * `{"ok":true}` while the session died, which is the worst combination available: the agent believes
8
- * it navigated, is looking at nothing, and has lost Reticle.
9
- *
10
- * `reticle_act` already draws this line — `dispatched` (sent) versus `settled` (a frame flushed).
11
- * This gives navigate the same honesty without changing what it does.
12
- */
13
1
  /** The session the SDK reconnected as after the page moved, when one was observed in the window. */
14
2
  export interface NavigateArrival {
15
3
  sessionId: string;
4
+ landedOn?: string;
16
5
  }
17
6
  export declare function navigateResult(result: {
18
7
  ok?: unknown;
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The honest envelope for a navigation nobody can confirm.
3
+ *
4
+ * `window.location.assign(url)` returns before the page moves, and the SDK that would report on the
5
+ * new document is destroyed by the navigation itself. So a successful NAVIGATE means the browser
6
+ * accepted the instruction — not that anything arrived. Driven against a dead URL it returned
7
+ * `{"ok":true}` while the session died, which is the worst combination available: the agent believes
8
+ * it navigated, is looking at nothing, and has lost Reticle.
9
+ *
10
+ * `reticle_act` already draws this line — `dispatched` (sent) versus `settled` (a frame flushed).
11
+ * This gives navigate the same honesty without changing what it does.
12
+ */
13
+ import { NAVIGATION_REDIRECT_NOTE } from '@reticlehq/core';
1
14
  export function navigateResult(result,
2
15
  /**
3
16
  * Arrival, if the daemon saw the SDK come back. `null` keeps the original honest envelope.
@@ -26,12 +39,17 @@ waitedMs) {
26
39
  if (!ok)
27
40
  return base;
28
41
  if (arrival !== null) {
42
+ const redirected = 'string' === typeof arrival.landedOn;
29
43
  return {
30
44
  ...base,
31
- confirmed: true,
32
- // The SDK that reconnects after a navigation is a NEW session with a new id. Returning it here
33
- // is the difference between a confirmation and a confirmation the agent can act on.
45
+ confirmed: !redirected,
34
46
  sessionId: arrival.sessionId,
47
+ ...(redirected
48
+ ? {
49
+ landedOn: arrival.landedOn,
50
+ note: `${NAVIGATION_REDIRECT_NOTE} Landed on: ${arrival.landedOn}`,
51
+ }
52
+ : {}),
35
53
  };
36
54
  }
37
55
  return {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Remember the control an act_and_wait PROVED, for the coverage ledger's `proved` level.
3
+ *
4
+ * Structural rather than `Session`, and optional, so a test stub that never proves anything need not
5
+ * implement it — the same reason `beginAction` is optional on the crawl session.
6
+ */
7
+ export declare function noteProved(session: {
8
+ recordProvedFrom?: (actPayload: unknown) => void;
9
+ }, verified: string, actPayload: unknown): void;
@@ -0,0 +1,11 @@
1
+ import { Verified } from '@reticlehq/core';
2
+ /**
3
+ * Remember the control an act_and_wait PROVED, for the coverage ledger's `proved` level.
4
+ *
5
+ * Structural rather than `Session`, and optional, so a test stub that never proves anything need not
6
+ * implement it — the same reason `beginAction` is optional on the crawl session.
7
+ */
8
+ export function noteProved(session, verified, actPayload) {
9
+ if (Verified.YES === verified)
10
+ session.recordProvedFrom?.(actPayload);
11
+ }
@@ -35,6 +35,11 @@ export interface StepExpectation {
35
35
  /** What was seen instead, on a miss — the half of the answer a repair actually needs. */
36
36
  observed?: string;
37
37
  expected?: string;
38
+ /**
39
+ * Contradiction kinds positively observed in this step's window — deciding ones only, the same
40
+ * filter act_and_wait applies. A held step with one is a UI that advanced over a failed write.
41
+ */
42
+ contradicted?: readonly string[];
38
43
  }
39
44
  export interface SequenceGrade {
40
45
  verified: Verified;
@@ -27,7 +27,9 @@ export function gradeSequence(steps, plan = { planned: steps.length, dispatched:
27
27
  const total = Math.max(plan.planned, steps.length);
28
28
  const declaring = steps.filter((step) => step.declared);
29
29
  const declared = declaring.length;
30
- const missed = declaring.find((step) => true !== step.held);
30
+ // `held: false` on an undeclared step means it could not be performed at all — the journey broke
31
+ // there whether or not anything was declared about it.
32
+ const missed = steps.find((step) => (step.declared ? true !== step.held : false === step.held));
31
33
  // A miss decides the grade before coverage does. How much of the plan declared nothing is
32
34
  // context on a red, never a softening of it.
33
35
  if (missed !== undefined) {
@@ -39,6 +41,17 @@ export function gradeSequence(steps, plan = { planned: steps.length, dispatched:
39
41
  because: `a declared consequence did not hold — observed ${observed}`,
40
42
  };
41
43
  }
44
+ // Same rule as a single action: an observed contradiction outranks a consequence that held.
45
+ const contradictedAt = steps.findIndex((step) => (step.contradicted ?? []).length > 0);
46
+ if (contradictedAt >= 0) {
47
+ const kinds = (steps[contradictedAt]?.contradicted ?? []).join(', ');
48
+ return {
49
+ verified: Verified.NO,
50
+ declared,
51
+ total,
52
+ because: `step ${String(contradictedAt + 1)}: channels disagree about this action (${kinds}) even though it went on`,
53
+ };
54
+ }
42
55
  /*
43
56
  * `unknown` rather than `no-fault`, and the distinction is load-bearing.
44
57
  *
@@ -68,6 +81,24 @@ export function gradeSequence(steps, plan = { planned: steps.length, dispatched:
68
81
  : `nothing was dispatched, so none of the ${String(total)} step(s) ran — read the per-step \`error\` for the one that was refused, and fix that before adding an \`expect\``,
69
82
  };
70
83
  }
84
+ // A journey is proved at its END. Declaring only early steps checks that it started, and a yes
85
+ // there was the one a single act_and_wait on the last step would never have given.
86
+ if (steps.length < total) {
87
+ return {
88
+ verified: Verified.UNKNOWN,
89
+ declared,
90
+ total,
91
+ because: `only ${String(steps.length)} of ${String(total)} steps ran, so the journey never reached its end`,
92
+ };
93
+ }
94
+ if (true !== steps.at(-1)?.declared) {
95
+ return {
96
+ verified: Verified.UNKNOWN,
97
+ declared,
98
+ total,
99
+ because: `the last step, where the journey ends, declared nothing — give it an \`expect\` naming the end state (${String(declared)} of ${String(total)} steps declared one)`,
100
+ };
101
+ }
71
102
  return {
72
103
  verified: Verified.YES,
73
104
  declared,
@@ -28,6 +28,13 @@ interface CapsuleSaveInputs {
28
28
  */
29
29
  root?: string | undefined;
30
30
  }
31
+ /**
32
+ * What the act claimed would happen, in words: `route /settings`, `POST /v1/pay → 200`,
33
+ * `element "Issues"`. Read off the predicate the agent declared, because the causal links only name
34
+ * signals and requests: a failed route, element or text claim used to be filed as "declared
35
+ * consequence", which tells whoever reads the capsule nothing about what was expected.
36
+ */
37
+ export declare function describeExpected(predicate: unknown): string | undefined;
31
38
  /** Returns the capsule id when one was written, or undefined when there was nothing to save. */
32
39
  export declare function saveFailedAssertCapsule(inputs: CapsuleSaveInputs): Promise<string | undefined>;
33
40
  export {};
@@ -14,6 +14,50 @@ import { CapsuleStore, capsuleFingerprint, capsuleId, CAPSULE_VERSION, } from '.
14
14
  import { ReticleTool } from '@reticlehq/core';
15
15
  import { asRecord, asString } from '@reticlehq/core';
16
16
  import { sessionRoot } from '../../memory/project/session-root.js';
17
+ /**
18
+ * What the act claimed would happen, in words: `route /settings`, `POST /v1/pay → 200`,
19
+ * `element "Issues"`. Read off the predicate the agent declared, because the causal links only name
20
+ * signals and requests: a failed route, element or text claim used to be filed as "declared
21
+ * consequence", which tells whoever reads the capsule nothing about what was expected.
22
+ */
23
+ export function describeExpected(predicate) {
24
+ const p = asRecord(predicate);
25
+ const kind = asString(p['kind']);
26
+ if (kind === undefined)
27
+ return undefined;
28
+ const all = (key, joiner) => {
29
+ const parts = (Array.isArray(p[key]) ? p[key] : [])
30
+ .map(describeExpected)
31
+ .filter((d) => d !== undefined);
32
+ return 0 === parts.length ? undefined : parts.join(joiner);
33
+ };
34
+ const q = asRecord(p['query']);
35
+ const named = asString(p['name']) ?? asString(q['name']) ?? asString(q['value']) ?? asString(q['testid']);
36
+ switch (kind) {
37
+ case 'route':
38
+ return `route ${asString(p['pathname']) ?? asString(p['contains']) ?? '?'}`;
39
+ case 'net': {
40
+ const status = 'number' === typeof p['status'] ? ` → ${String(p['status'])}` : '';
41
+ return `${asString(p['method']) ?? 'a'} ${asString(p['urlContains']) ?? 'request'}${status}`;
42
+ }
43
+ case 'signal':
44
+ return `signal ${asString(p['name']) ?? '?'}`;
45
+ case 'element':
46
+ return `${true === p['absent'] ? 'no ' : ''}element ${named === undefined ? '?' : `"${named}"`}`;
47
+ case 'text':
48
+ return `${true === p['absent'] ? 'no ' : ''}text "${asString(p['contains']) ?? '?'}"`;
49
+ case 'allOf':
50
+ return all('predicates', ' AND ');
51
+ case 'anyOf':
52
+ return all('predicates', ' OR ');
53
+ case 'not': {
54
+ const inner = describeExpected(p['predicate']);
55
+ return inner === undefined ? undefined : `not ${inner}`;
56
+ }
57
+ default:
58
+ return kind;
59
+ }
60
+ }
17
61
  /** Returns the capsule id when one was written, or undefined when there was nothing to save. */
18
62
  export async function saveFailedAssertCapsule(inputs) {
19
63
  const { deps, verdict, capsule, links, args, actResult, actedSource, root: given } = inputs;
@@ -47,7 +91,8 @@ export async function saveFailedAssertCapsule(inputs) {
47
91
  const body = {
48
92
  version: CAPSULE_VERSION,
49
93
  origin: 'failed-assert',
50
- expected: expectedText.length > 0 ? expectedText : 'declared consequence',
94
+ expected: describeExpected(args['until'] ?? args['predicate']) ??
95
+ (expectedText.length > 0 ? expectedText : 'declared consequence'),
51
96
  observed: capsule.firstDivergence?.observed ?? verdict.failureReason ?? 'not observed',
52
97
  steps: [
53
98
  {
@@ -11,12 +11,12 @@
11
11
  */
12
12
  import { z } from 'zod';
13
13
  import { timeoutMsSchema } from './args/numeric-bounds.js';
14
- import { compileSequenceStep } from '../../language/flows/replay.js';
14
+ import { compileSequenceStep, pathOf } from '../../language/flows/replay.js';
15
15
  import { sequenceStepArgs } from './act/act-preflight.js';
16
16
  import { ReticleTool } from '@reticlehq/core';
17
17
  import { healthEnvelope } from '../../portal/session/session-health.js';
18
18
  import { pausedShortCircuit, pausedOutputShape, withControl, } from '../../portal/session/control-envelope.js';
19
- import { asRecord } from '@reticlehq/core';
19
+ import { asRecord, isAbsenceDerived, isAdvisory } from '@reticlehq/core';
20
20
  import { sessionIdFromArgs } from './tools-helpers.js';
21
21
  import { describeStepResult, runStepWithStaleRetry } from './act/act-sequence-retry.js';
22
22
  import { assertSequenceSteps } from './act/act-preflight.js';
@@ -59,10 +59,9 @@ function effectOf(session, since) {
59
59
  const BURST_MAX_GAP_MS = 500;
60
60
  export const ACT_SEQUENCE_TOOL = {
61
61
  name: ReticleTool.ACT_SEQUENCE,
62
- // The example is required for a core tool, and this one carries weight: the measured loop it
63
- // replaces is literally a login form driven as three separate reticle_act calls (98 clicks and
64
- // 21 fills inside looping sessions, 2026-08-10/11). Showing fill -> fill -> click is showing the
65
- // exact shape an agent otherwise spends three round trips on.
62
+ // The example is required for a core tool, and this one carries weight: the loop it replaces is
63
+ // a login form driven as three separate reticle_act calls. Showing fill -> fill -> click is showing
64
+ // the exact shape an agent otherwise spends three round trips on.
66
65
  example: {
67
66
  steps: [
68
67
  { ref: 'e12', action: 'fill', args: { value: 'a@b.com' } },
@@ -133,6 +132,7 @@ export const ACT_SEQUENCE_TOOL = {
133
132
  if (paused !== undefined)
134
133
  return paused;
135
134
  const since = session.elapsed();
135
+ const pageBefore = pathOf(session.url);
136
136
  session.beginAction(ReticleTool.ACT_SEQUENCE, asRecord(args));
137
137
  try {
138
138
  const inputSteps = Array.isArray(args['steps']) ? args['steps'] : [];
@@ -230,8 +230,12 @@ export const ACT_SEQUENCE_TOOL = {
230
230
  * satisfy step four's assertion, which is a false green built out of correct parts.
231
231
  */
232
232
  const parsed = PredicateSchema.safeParse(step['expect']);
233
+ const contradicted = (described.contradictions ?? [])
234
+ .map((c) => c.kind)
235
+ .filter((kind) => !isAdvisory(kind) && !isAbsenceDerived(kind));
236
+ const disagreement = 0 === contradicted.length ? {} : { contradicted };
233
237
  if (!parsed.success) {
234
- expectations.push({ declared: false });
238
+ expectations.push({ declared: false, ...disagreement });
235
239
  stepResults.push(described);
236
240
  }
237
241
  else {
@@ -240,6 +244,7 @@ export const ACT_SEQUENCE_TOOL = {
240
244
  expectations.push({
241
245
  declared: true,
242
246
  held,
247
+ ...disagreement,
243
248
  ...(verdict.observed === undefined ? {} : { observed: verdict.observed }),
244
249
  ...(verdict.expected === undefined ? {} : { expected: verdict.expected }),
245
250
  });
@@ -287,7 +292,19 @@ export const ACT_SEQUENCE_TOOL = {
287
292
  // Same as captureAct: no `active()` gate, so a sequence driven with nothing open still lands
288
293
  // in the ambient tape. A stalled plan is still excluded — those steps never ran.
289
294
  if (stalledAt === undefined) {
290
- deps.recordings.capture(compileSequenceStep(args, { count: inputSteps.length, steps: stepResults }));
295
+ // A sub-step keeps its `expect` only if it held — same rule as act_and_wait, so a flow
296
+ // never replays an expectation that was never once observed to hold.
297
+ const proved = inputSteps.map((raw, i) => {
298
+ const step = asRecord(raw);
299
+ const { expect: _expect, ...rest } = step;
300
+ return true === expectations[i]?.held ? step : rest;
301
+ });
302
+ const recorded = compileSequenceStep({ ...args, steps: proved }, {
303
+ count: inputSteps.length,
304
+ steps: stepResults,
305
+ });
306
+ deps.recordings.capture(pageBefore === undefined ? recorded : { ...recorded, page: pageBefore });
307
+ deps.recordings.markEnded(pathOf(session.url));
291
308
  }
292
309
  /*
293
310
  * The grade, and the coverage it is never allowed to hide.
@@ -3,23 +3,8 @@ import { aliasParam } from './args/alias-args.js';
3
3
  import { resolveSessionWithin } from '../../portal/session/timing/resolve-within.js';
4
4
  import { WALL_CLOCK } from '../../portal/session/timing/wall-clock.js';
5
5
  import { timeoutMsSchema } from './args/numeric-bounds.js';
6
- import { captureAct } from '../../language/flows/replay.js';
7
- /**
8
- * The pathname of a session's live url, or undefined when it has none yet.
9
- *
10
- * Pathname rather than the whole url, because that is what `startPath` is compared against and what
11
- * a hash-routed app makes meaningless in the document location — see routeOfEvent.
12
- */
13
- function pathOf(url) {
14
- if (url === undefined)
15
- return undefined;
16
- try {
17
- return new URL(url).pathname;
18
- }
19
- catch {
20
- return undefined;
21
- }
22
- }
6
+ import { captureAct, captureVerdictedAct, pathOf } from '../../language/flows/replay.js';
7
+ import { durableArg, durableOutput, reloadAndRecheck, withDurability } from './act/durable.js';
23
8
  import { ActionType, ActionWarning, ReticleCommand, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, Verified, VerifiedReason, PredicateKind, } from '@reticlehq/core';
24
9
  import { leanActResult, mutatedWithin } from './act/act-view.js';
25
10
  import { ReticleTool } from '@reticlehq/core';
@@ -37,8 +22,9 @@ import { intentDebt, openSessionIntents } from '../../memory/intent/open-intents
37
22
  import { dischargeInlineIntent, inlineVerdictId, linkInlineIntent, } from '../../memory/intent/inline-intent.js';
38
23
  import { declaresState } from '@reticlehq/engine/question/predicate/predicate-asks.js';
39
24
  import { isStateUnwatched } from '@reticlehq/engine/evidence/blind-spots.js';
40
- import { inFlightRequestLabels, repeatedRequestLabels, waitForInFlight, } from './act/settle-in-flight.js';
41
- import { waitForReaction } from './act/react-grace.js';
25
+ import { inFlightRequestLabels, repeatedRequestLabels } from './act/settle-in-flight.js';
26
+ import { finishAfterMatch } from './act/after-match.js';
27
+ import { noteProved } from './act/proved-controls.js';
42
28
  import { decideVerified } from '@reticlehq/engine/evidence/verified.js';
43
29
  import { honestyForVerdict } from '@reticlehq/engine/evidence/honesty.js';
44
30
  import { declaredExpectations, declaresBodyIndependentChannel, } from '@reticlehq/engine/question/declared.js';
@@ -254,6 +240,7 @@ export const ACT_TOOLS = [
254
240
  });
255
241
  }
256
242
  finally {
243
+ deps.recordings.markEnded(pathOf(session.url));
257
244
  // Close the window on every exit (settle or throw), recording the action + settle outcome.
258
245
  session.finishAction(undefined, settledOutcome, true === settledOutcome ? session.elapsed() - since : undefined);
259
246
  }
@@ -286,7 +273,7 @@ export const ACT_TOOLS = [
286
273
  .record(z.unknown())
287
274
  .optional()
288
275
  .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { modifiers: ["Meta","Shift"] } for a press shortcut (Meta/Control/Shift/Alt), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control. For upload: { path } is a path on disk (absolute or relative to project root; daemon reads real bytes) or { name, content?, type? } for inline bytes.'),
289
- predicate: PredicateSchema.optional().describe('Alias for `until` (the name reticle_assert / reticle_wait_for use).'),
276
+ predicate: PredicateSchema.optional().describe('Alias for `until`.'),
290
277
  until: PredicateSchema.optional().describe('Predicate to wait for after the action completes (same shape as reticle_assert). OMIT to wait for the page to SETTLE — network + DOM idle — the deterministic default instead of a sleep. To assert a consequence AND settle, allOf them: { kind: "allOf", predicates: [<your predicate>, { kind: "settled" }] }.'),
291
278
  timeout_ms: timeoutMsSchema
292
279
  .optional()
@@ -296,9 +283,11 @@ export const ACT_TOOLS = [
296
283
  .optional()
297
284
  .describe('Throw if the tab is throttled. Default: false.'),
298
285
  intent: intentArg,
286
+ durable: durableArg,
299
287
  ...sessionIdShape,
300
288
  },
301
289
  outputSchema: {
290
+ durable: durableOutput,
302
291
  effect: z
303
292
  .unknown()
304
293
  .describe('The reticle_act result (dispatched, settled, inputMode, etc.).'),
@@ -433,6 +422,9 @@ export const ACT_TOOLS = [
433
422
  const { alreadyTrue, alreadyTrueHiddenMatch, alreadyTrueEvidence, baselines } = await readAlreadyTrue(session, until, since);
434
423
  // Same as the ACT handler: the route this step RAN on, before the action can move the app.
435
424
  const routeBeforeWait = pathOf(session.url);
425
+ // Recorded in `finally`, once the verdict is known — see captureVerdictedAct.
426
+ let dispatched;
427
+ let recordedVerdict;
436
428
  try {
437
429
  // actCommand is the single interception point for upload+path rewrite.
438
430
  //
@@ -450,7 +442,7 @@ export const ACT_TOOLS = [
450
442
  if (actResult !== null) {
451
443
  if (!actResult.ok)
452
444
  throw new Error(actResult.error ?? 'act failed');
453
- captureAct(deps.recordings, args, actResult.result, routeBeforeWait);
445
+ dispatched = { result: actResult.result };
454
446
  // Dispatched — now this act owns the cursor and the effect. Marking its OWN measurement
455
447
  // also stops the spread below from inheriting an earlier reticle_act's action and
456
448
  // mutation count.
@@ -476,34 +468,12 @@ export const ACT_TOOLS = [
476
468
  since = 0;
477
469
  session = followed.session;
478
470
  verdict = followed.verdict;
479
- // The predicate resolves the INSTANT it holds, which on an optimistically-navigating app is
480
- // while the write is still in flight — so the verdict was taken over a window the app had not
481
- // finished, and came back `unknown / unsettled` asking the CALLER to re-check. A login that
482
- // genuinely succeeded returned at 492ms of an 8000ms budget with every corroborating channel
483
- // agreeing. Spend the rest of the budget the caller already granted.
484
- //
485
- // This decides nothing: it waits for the answer instead of guessing at it. A response that
486
- // arrives as a FAILURE still contradicts — and now arrives INSIDE the window, where the
487
- // detector can see it at all — while a request still open when the budget genuinely runs out
488
- // reports unsettled exactly as before, an honest limit rather than an early exit. Costs
489
- // nothing on the common path, where no request is in flight.
471
+ // After a match, let the app finish before the window closes — see act/after-match.ts.
490
472
  if (verdict.pass && timeout > 0) {
491
- const sleep = {
492
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
493
- };
494
- const spent = () => timeout - (session.elapsed() - predicateStarted);
495
- await waitForInFlight(session, since, spent(), sleep);
496
- // Waiting for the response moved the hazard rather than removing it: the response now lands
497
- // inside the window BY DESIGN, and the app's re-render happens a task or two later. Close
498
- // the window in that gap and every channel agrees the app took a successful write and did
499
- // nothing — `response-ignored`, i.e. `verified:"no"` on a correct app, produced entirely by
500
- // where we stopped looking. A false accusation is the more damaging direction of error for
501
- // a verification tool: it sends someone to fix code that is not broken.
502
- //
503
- // So the response is not the end of the window; the app's REACTION to it is. Paid only in
504
- // the shape that would otherwise be accused — a successful mutating write with nothing
505
- // moved after it — and short enough that a genuinely dropped response is still reported.
506
- await waitForReaction(session, since, spent(), sleep);
473
+ const left = () => timeout - (session.elapsed() - predicateStarted);
474
+ const reverted = await finishAfterMatch(session, until, since, baselines, left);
475
+ if (reverted !== undefined)
476
+ verdict = { pass: false, failureReason: reverted, observed: reverted };
507
477
  }
508
478
  const r = asRecord(actResult?.result);
509
479
  if ('boolean' === typeof r['settled'])
@@ -650,7 +620,7 @@ export const ACT_TOOLS = [
650
620
  const outcomePending = acceptedWriteLabels(windowEvents);
651
621
  const outcomeUnread = unreadWriteLabels(windowEvents);
652
622
  const stillInFlight = inFlightRequestLabels(windowEvents, session.url, session.background);
653
- const decision = decideVerified({
623
+ const { decision, durable } = await withDurability(args['durable'], until, decideVerified({
654
624
  pass: verdict.pass,
655
625
  // Threaded rather than looked up: decideVerified is pure and has no session.
656
626
  ...sessionVerdictFacts(session),
@@ -690,7 +660,9 @@ export const ACT_TOOLS = [
690
660
  repeated: repeatedRequestLabels(windowEvents),
691
661
  },
692
662
  ...(namedNetIsInFlight(until, stillInFlight) ? { namedRequestInFlight: true } : {}),
693
- });
663
+ }), (part) => reloadAndRecheck(deps, session, part, timeout));
664
+ recordedVerdict = String(decision.verified);
665
+ noteProved(session, recordedVerdict, actResult?.result);
694
666
  // Computed once: the verdict block reports it, and the instrumentation gaps are a second
695
667
  // reading of the same evidence rather than a new observation.
696
668
  const actionSummary = causalSummary(windowEvents, {
@@ -818,6 +790,7 @@ export const ACT_TOOLS = [
818
790
  // agent cannot miss it by not asking. Omitted entirely when clean, so a healthy action
819
791
  // pays nothing.
820
792
  ...(contradictions.length > 0 ? { contradictions } : {}),
793
+ ...(durable === undefined ? {} : { durable }),
821
794
  honesty: honestyForVerdict(String(decision.verified), honesty),
822
795
  // Without its summary: it is byte-identical to `summary` two fields up. See wireCapsule.
823
796
  ...(capsule === undefined ? {} : { capsule: wireCapsule(capsule) }),
@@ -828,6 +801,9 @@ export const ACT_TOOLS = [
828
801
  });
829
802
  }
830
803
  finally {
804
+ if (dispatched !== undefined)
805
+ captureVerdictedAct(deps.recordings, args, dispatched.result, routeBeforeWait, recordedVerdict);
806
+ deps.recordings.markEnded(pathOf(currentOf(deps.sessions, session).url));
831
807
  acted.finishAction(verdictEffect, settledOutcome, true === settledOutcome ? acted.elapsed() - actedSince : undefined);
832
808
  }
833
809
  },
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Pure: no IO, no clock.
12
12
  */
13
- import { isConsequenceKind, isPresenceKind, PredicateKind } from '@reticlehq/core';
13
+ import { compareSourceClauses, isConsequenceKind, isPresenceKind, PredicateKind, } from '@reticlehq/core';
14
14
  import { HonestyGrade, strongerGrade, weakerGrade } from '@reticlehq/engine/evidence/honesty.js';
15
15
  export const PRESENCE_ONLY_ADVICE = 'This predicate only checks element/text presence, not an observable consequence. A locator healed to the wrong element (or a stale render) can satisfy it while the feature is broken. Prefer a { signal } or { net } assertion (or allOf it with one) so green means the feature actually worked.';
16
16
  function walk(predicate, negated = false) {
@@ -27,6 +27,14 @@ function walk(predicate, negated = false) {
27
27
  // never matched anything in the first place. Negation makes a presence check weaker, not stronger.
28
28
  if (PredicateKind.NOT === predicate.kind)
29
29
  return walk(predicate.predicate, true);
30
+ // A comparison grades as what its sides read: with a net/signal/state side it relates the page to
31
+ // the app's own record, which a wrong locator cannot fake; text against text is two readings of
32
+ // the same DOM. Its text side is always a content check, so it never counts as bare presence
33
+ // unless negated.
34
+ if (PredicateKind.COMPARE === predicate.kind) {
35
+ const consequence = compareSourceClauses(predicate).some((c) => isConsequenceKind(c.kind));
36
+ return { consequence, presence: !consequence && negated };
37
+ }
30
38
  // Leaf: classify against the single source of truth in core. Kinds that are neither consequence
31
39
  // nor presence (route/console/settled/animation) correctly return false for both.
32
40
  return {
@@ -109,6 +117,9 @@ export function gradeOfPredicate(predicate) {
109
117
  // verdict that proved only presence report `signal`, and a `minGrade: net` gate would trust it.
110
118
  case PredicateKind.ANY_OF:
111
119
  return combine(predicate.predicates, weakerGrade);
120
+ // Both sides had to be read and agree, so it proves as much as its stronger side.
121
+ case PredicateKind.COMPARE:
122
+ return combine(compareSourceClauses(predicate), strongerGrade);
112
123
  // Everything else, `not` included: an absence claim is satisfied trivially by a locator that
113
124
  // never matched anything, which is the argument `walk` already makes about negation.
114
125
  default:
@@ -1,4 +1,4 @@
1
- import { CaptureLoss, channelsRead, PredicateKind } from '@reticlehq/core';
1
+ import { CaptureLoss, channelsReadBy, PredicateKind } from '@reticlehq/core';
2
2
  import { sessionVerdictFacts } from '../../../portal/session/session-verdict-facts.js';
3
3
  import { gapsForAction } from '@reticlehq/engine/evidence/instrumentation-gaps.js';
4
4
  import { noteSessionGaps } from '@reticlehq/engine/evidence/gap-ledger.js';
@@ -148,7 +148,7 @@ changeUndeclared) {
148
148
  // first clause, and one this implementation could not run until the page started declaring.
149
149
  // Both halves are conditional on purpose: an SDK too old to declare sends nothing, and
150
150
  // treating that silence as an empty set would refuse every claim from every older page.
151
- channelsRead: channelsRead(predicate.kind),
151
+ channelsRead: channelsReadBy(predicate),
152
152
  ...(session.channels === undefined ? {} : { channelsObservable: session.channels }),
153
153
  // Threaded rather than looked up: decideVerified is pure and has no session.
154
154
  ...sessionVerdictFacts(session),