@reticlehq/server 2.7.0 → 2.9.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 (357) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +130 -778
  3. package/dist/bridge/bridge.d.ts +23 -0
  4. package/dist/bridge/bridge.js +80 -2
  5. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  6. package/dist/bridge/bridge.test-harness.js +6 -4
  7. package/dist/capsule/causal-summary.d.ts +36 -1
  8. package/dist/capsule/causal-summary.js +33 -7
  9. package/dist/capsule/divergence.js +7 -1
  10. package/dist/cli/chromium-hint.d.ts +33 -5
  11. package/dist/cli/chromium-hint.js +82 -12
  12. package/dist/cli/cli-doctor.js +65 -34
  13. package/dist/cli/cli-flow-commands.js +8 -8
  14. package/dist/cli/cli-kill.d.ts +58 -0
  15. package/dist/cli/cli-kill.js +162 -0
  16. package/dist/cli/cli-parse.d.ts +16 -1
  17. package/dist/cli/cli-parse.js +32 -9
  18. package/dist/cli/cli-port.d.ts +32 -0
  19. package/dist/cli/cli-port.js +82 -35
  20. package/dist/cli/cli-verify.d.ts +21 -0
  21. package/dist/cli/cli-verify.js +47 -3
  22. package/dist/cli/config-discovery.d.ts +35 -0
  23. package/dist/cli/config-discovery.js +163 -0
  24. package/dist/cli/doctor-daemon-line.js +4 -1
  25. package/dist/cli/doctor-project-line.d.ts +37 -0
  26. package/dist/cli/doctor-project-line.js +35 -0
  27. package/dist/cli/doctor-rows.d.ts +51 -0
  28. package/dist/cli/doctor-rows.js +54 -0
  29. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  30. package/dist/cli/doctor-sessions-line.js +69 -0
  31. package/dist/cli/drive-attach.d.ts +99 -0
  32. package/dist/cli/drive-attach.js +203 -0
  33. package/dist/cli/drive-command.d.ts +13 -0
  34. package/dist/cli/drive-command.js +103 -0
  35. package/dist/cli/open-note.d.ts +20 -0
  36. package/dist/cli/open-note.js +37 -0
  37. package/dist/cli/port-holder.d.ts +10 -0
  38. package/dist/cli/port-holder.js +26 -2
  39. package/dist/cli/status-next-action.d.ts +37 -0
  40. package/dist/cli/status-next-action.js +61 -0
  41. package/dist/cli.js +119 -39
  42. package/dist/crawl/crawl-tools.js +12 -2
  43. package/dist/crawl/crawl.d.ts +13 -0
  44. package/dist/crawl/crawl.js +50 -2
  45. package/dist/events/contradictions.d.ts +46 -0
  46. package/dist/events/contradictions.js +35 -22
  47. package/dist/events/declared.d.ts +45 -0
  48. package/dist/events/declared.js +73 -0
  49. package/dist/events/echo-mismatch.d.ts +8 -2
  50. package/dist/events/echo-mismatch.js +33 -5
  51. package/dist/events/event-filters.d.ts +1 -1
  52. package/dist/events/event-filters.js +7 -4
  53. package/dist/events/predicate-eval.d.ts +17 -72
  54. package/dist/events/predicate-eval.js +117 -236
  55. package/dist/events/predicate-parse.js +31 -3
  56. package/dist/events/predicate-route.d.ts +32 -0
  57. package/dist/events/predicate-route.js +102 -0
  58. package/dist/events/predicate-schema.d.ts +99 -0
  59. package/dist/events/predicate-schema.js +309 -0
  60. package/dist/events/predicate.d.ts +8 -0
  61. package/dist/events/predicate.js +296 -22
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-classify.js +1 -1
  69. package/dist/flows/flow-replay-run.js +8 -1
  70. package/dist/flows/flow-tools.js +21 -5
  71. package/dist/flows/heal-run.js +1 -1
  72. package/dist/flows/replay.js +3 -0
  73. package/dist/honesty/accepted-write.d.ts +3 -0
  74. package/dist/honesty/accepted-write.js +8 -3
  75. package/dist/honesty/already-true.js +15 -0
  76. package/dist/honesty/blind-spots.js +10 -2
  77. package/dist/honesty/honesty.d.ts +11 -0
  78. package/dist/honesty/honesty.js +1 -0
  79. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  80. package/dist/honesty/uncaptured-bodies.js +9 -2
  81. package/dist/honesty/unread-outcome.d.ts +8 -0
  82. package/dist/honesty/unread-outcome.js +36 -9
  83. package/dist/honesty/unsettled.d.ts +49 -0
  84. package/dist/honesty/unsettled.js +82 -0
  85. package/dist/honesty/verified.d.ts +36 -3
  86. package/dist/honesty/verified.js +54 -11
  87. package/dist/http-server.d.ts +19 -0
  88. package/dist/http-server.js +202 -4
  89. package/dist/impact/impact-recorder.d.ts +23 -0
  90. package/dist/impact/impact-recorder.js +76 -0
  91. package/dist/impact/impact-store.d.ts +47 -0
  92. package/dist/impact/impact-store.js +171 -0
  93. package/dist/index.d.ts +7 -0
  94. package/dist/index.js +47 -10
  95. package/dist/init/agent-rules.d.ts +36 -0
  96. package/dist/init/agent-rules.js +132 -22
  97. package/dist/init/astro-patch.d.ts +2 -1
  98. package/dist/init/astro-patch.js +38 -6
  99. package/dist/init/capabilities.d.ts +22 -0
  100. package/dist/init/capabilities.js +76 -0
  101. package/dist/init/confirm.d.ts +114 -0
  102. package/dist/init/confirm.js +165 -0
  103. package/dist/init/connect-steps.d.ts +2 -0
  104. package/dist/init/connect-steps.js +50 -0
  105. package/dist/init/cra.js +3 -2
  106. package/dist/init/csp-check.d.ts +38 -0
  107. package/dist/init/csp-check.js +92 -0
  108. package/dist/init/csp-doctor.d.ts +25 -0
  109. package/dist/init/csp-doctor.js +49 -0
  110. package/dist/init/cursor.js +6 -9
  111. package/dist/init/detect.d.ts +8 -0
  112. package/dist/init/detect.js +33 -3
  113. package/dist/init/mcp-clients.d.ts +1 -0
  114. package/dist/init/mcp-clients.js +51 -7
  115. package/dist/init/mcp.d.ts +15 -0
  116. package/dist/init/mcp.js +19 -0
  117. package/dist/init/non-js-project.js +29 -5
  118. package/dist/init/plan-framework.d.ts +10 -0
  119. package/dist/init/plan-framework.js +78 -16
  120. package/dist/init/plan.d.ts +47 -6
  121. package/dist/init/plan.js +228 -69
  122. package/dist/init/run.d.ts +24 -2
  123. package/dist/init/run.js +157 -45
  124. package/dist/init/slash-command.d.ts +1 -1
  125. package/dist/init/slash-command.js +54 -6
  126. package/dist/init/snippets.d.ts +45 -5
  127. package/dist/init/snippets.js +194 -42
  128. package/dist/init/vite-config.js +53 -5
  129. package/dist/input/network-mock-tools.d.ts +19 -0
  130. package/dist/input/network-mock-tools.js +37 -5
  131. package/dist/input/scroll-find.d.ts +6 -0
  132. package/dist/input/scroll-find.js +17 -2
  133. package/dist/input/scroll-tools.js +4 -0
  134. package/dist/journal/attach-journal.d.ts +9 -1
  135. package/dist/journal/attach-journal.js +13 -2
  136. package/dist/journal/journal-query.d.ts +13 -0
  137. package/dist/journal/journal-query.js +28 -3
  138. package/dist/journal/workspace-gitignore.d.ts +10 -0
  139. package/dist/journal/workspace-gitignore.js +59 -0
  140. package/dist/license/license.js +1 -1
  141. package/dist/mcp/mcp-proxy.d.ts +35 -30
  142. package/dist/mcp/mcp-proxy.js +306 -104
  143. package/dist/mcp/mcp.d.ts +10 -1
  144. package/dist/mcp/mcp.js +70 -34
  145. package/dist/mcp/proxy-handshake.d.ts +82 -1
  146. package/dist/mcp/proxy-handshake.js +109 -2
  147. package/dist/mcp/proxy-lifecycle.d.ts +11 -2
  148. package/dist/mcp/proxy-lifecycle.js +12 -3
  149. package/dist/mcp/proxy-log.d.ts +63 -0
  150. package/dist/mcp/proxy-log.js +132 -0
  151. package/dist/mcp/server-instructions.d.ts +17 -0
  152. package/dist/mcp/server-instructions.js +56 -0
  153. package/dist/mcp/shared-params.d.ts +27 -0
  154. package/dist/mcp/shared-params.js +36 -0
  155. package/dist/pool/browser-pool.d.ts +22 -2
  156. package/dist/pool/browser-pool.js +44 -4
  157. package/dist/project/learned-routes.d.ts +24 -0
  158. package/dist/project/learned-routes.js +63 -0
  159. package/dist/project/project-store.d.ts +6 -0
  160. package/dist/project/project-store.js +25 -1
  161. package/dist/project/reticle-dir.d.ts +2 -0
  162. package/dist/project/reticle-dir.js +1 -0
  163. package/dist/session/connection-memory.d.ts +59 -0
  164. package/dist/session/connection-memory.js +108 -0
  165. package/dist/session/dev-command.d.ts +26 -0
  166. package/dist/session/dev-command.js +89 -0
  167. package/dist/session/dev-server-probe.d.ts +68 -0
  168. package/dist/session/dev-server-probe.js +102 -7
  169. package/dist/session/last-act.d.ts +26 -0
  170. package/dist/session/last-act.js +35 -1
  171. package/dist/session/live-control-tools.js +42 -5
  172. package/dist/session/no-session-diagnosis.d.ts +67 -2
  173. package/dist/session/no-session-diagnosis.js +236 -27
  174. package/dist/session/no-session-next-action.d.ts +46 -0
  175. package/dist/session/no-session-next-action.js +85 -0
  176. package/dist/session/no-session-watch.d.ts +40 -1
  177. package/dist/session/no-session-watch.js +174 -8
  178. package/dist/session/pending-commands.d.ts +12 -0
  179. package/dist/session/pending-commands.js +13 -1
  180. package/dist/session/ref-provenance.d.ts +54 -0
  181. package/dist/session/ref-provenance.js +61 -0
  182. package/dist/session/session-lease.js +1 -1
  183. package/dist/session/session-manager.d.ts +4 -0
  184. package/dist/session/session-manager.js +35 -5
  185. package/dist/session/session.d.ts +10 -1
  186. package/dist/session/session.js +146 -5
  187. package/dist/telemetry/app-instrumented.d.ts +8 -0
  188. package/dist/telemetry/app-instrumented.js +10 -0
  189. package/dist/telemetry/automation-hint.d.ts +17 -0
  190. package/dist/telemetry/automation-hint.js +71 -0
  191. package/dist/telemetry/bug-found.d.ts +15 -17
  192. package/dist/telemetry/bug-found.js +52 -21
  193. package/dist/telemetry/cli-telemetry.js +7 -1
  194. package/dist/telemetry/daemon-telemetry.js +24 -0
  195. package/dist/telemetry/feedback.js +1 -1
  196. package/dist/telemetry/init-telemetry.js +9 -1
  197. package/dist/telemetry/install-source.d.ts +56 -0
  198. package/dist/telemetry/install-source.js +74 -0
  199. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  200. package/dist/telemetry/instrumentation-stall.js +90 -0
  201. package/dist/telemetry/run-telemetry.js +4 -1
  202. package/dist/telemetry/session-metrics.d.ts +8 -0
  203. package/dist/telemetry/session-metrics.js +15 -0
  204. package/dist/telemetry/telemetry.d.ts +7 -1
  205. package/dist/telemetry/telemetry.js +26 -2
  206. package/dist/telemetry/tool-refused.d.ts +19 -0
  207. package/dist/telemetry/tool-refused.js +55 -0
  208. package/dist/telemetry/verification-of.js +46 -7
  209. package/dist/temp-dir.d.ts +27 -0
  210. package/dist/temp-dir.js +46 -0
  211. package/dist/tools/act-tools.js +209 -28
  212. package/dist/tools/assert-grade.d.ts +1 -1
  213. package/dist/tools/assert-grade.js +27 -5
  214. package/dist/tools/assert-verdict.js +49 -7
  215. package/dist/tools/browser-tools.js +10 -0
  216. package/dist/tools/contract-tools.js +4 -0
  217. package/dist/tools/dynamic-tools.js +16 -7
  218. package/dist/tools/error-recovery.d.ts +10 -0
  219. package/dist/tools/error-recovery.js +73 -9
  220. package/dist/tools/feedback-invite.js +1 -1
  221. package/dist/tools/invoke-tool.js +159 -26
  222. package/dist/tools/lease-hint.d.ts +30 -8
  223. package/dist/tools/lease-hint.js +65 -14
  224. package/dist/tools/lease-tools.d.ts +40 -1
  225. package/dist/tools/lease-tools.js +181 -83
  226. package/dist/tools/navigate-result.js +1 -1
  227. package/dist/tools/observe-tools.js +10 -4
  228. package/dist/tools/observed-nothing.js +1 -1
  229. package/dist/tools/react-grace.d.ts +52 -0
  230. package/dist/tools/react-grace.js +90 -0
  231. package/dist/tools/read-tools.js +67 -5
  232. package/dist/tools/resolve-target.d.ts +33 -0
  233. package/dist/tools/resolve-target.js +53 -0
  234. package/dist/tools/settle-in-flight.d.ts +94 -0
  235. package/dist/tools/settle-in-flight.js +146 -0
  236. package/dist/tools/snapshot-delta.d.ts +10 -0
  237. package/dist/tools/snapshot-delta.js +194 -9
  238. package/dist/tools/tool-kit.d.ts +8 -0
  239. package/dist/tools/tool-surface.d.ts +60 -0
  240. package/dist/tools/tool-surface.js +86 -1
  241. package/dist/tools/tools.js +61 -8
  242. package/dist/tools/unadvertised-help.js +9 -6
  243. package/docs/README.md +55 -11
  244. package/docs/actions.mdx +120 -0
  245. package/docs/agent-cheatsheet.md +95 -29
  246. package/docs/architecture.md +52 -35
  247. package/docs/benchmarks.md +127 -75
  248. package/docs/best-practices.mdx +216 -0
  249. package/docs/capabilities.mdx +534 -0
  250. package/docs/cli/affected.mdx +53 -0
  251. package/docs/cli/capsules.mdx +70 -0
  252. package/docs/cli/cloud.mdx +153 -0
  253. package/docs/cli/doctor.mdx +109 -0
  254. package/docs/cli/drive.mdx +74 -0
  255. package/docs/cli/feedback.mdx +67 -0
  256. package/docs/cli/gate.mdx +64 -0
  257. package/docs/cli/help.mdx +94 -0
  258. package/docs/cli/hunt.mdx +73 -0
  259. package/docs/cli/identify.mdx +76 -0
  260. package/docs/cli/init.mdx +121 -0
  261. package/docs/cli/kill.mdx +91 -0
  262. package/docs/cli/license.mdx +48 -0
  263. package/docs/cli/mcp.mdx +61 -0
  264. package/docs/cli/open.mdx +74 -0
  265. package/docs/cli/restart.mdx +60 -0
  266. package/docs/cli/rollback.mdx +35 -0
  267. package/docs/cli/serve.mdx +69 -0
  268. package/docs/cli/status.mdx +88 -0
  269. package/docs/cli/stop.mdx +64 -0
  270. package/docs/cli/telemetry.mdx +57 -0
  271. package/docs/cli/update.mdx +52 -0
  272. package/docs/cli/verify.mdx +76 -0
  273. package/docs/cli/version.mdx +39 -0
  274. package/docs/cli/watch.mdx +62 -0
  275. package/docs/cli.mdx +241 -0
  276. package/docs/debugging.md +16 -10
  277. package/docs/deploy-checks.md +45 -12
  278. package/docs/desktop.mdx +384 -0
  279. package/docs/docs.json +364 -0
  280. package/docs/enterprise.md +22 -16
  281. package/docs/faq.mdx +102 -0
  282. package/docs/fixtures.md +13 -7
  283. package/docs/flows.md +29 -23
  284. package/docs/for-agents.md +71 -0
  285. package/docs/frameworks.mdx +238 -0
  286. package/docs/gate-plan.md +48 -42
  287. package/docs/gates.md +28 -22
  288. package/docs/getting-started.md +129 -77
  289. package/docs/human-control.md +24 -18
  290. package/docs/index.mdx +143 -0
  291. package/docs/install-agentic.mdx +224 -0
  292. package/docs/install-manual.mdx +196 -0
  293. package/docs/instrumentation.mdx +238 -0
  294. package/docs/integration-patterns.md +41 -35
  295. package/docs/local-registry.md +19 -13
  296. package/docs/multi-agent-testing.md +19 -13
  297. package/docs/packages/babel-plugin.mdx +79 -0
  298. package/docs/packages/browser.mdx +111 -0
  299. package/docs/packages/core.mdx +116 -0
  300. package/docs/packages/electron.mdx +85 -0
  301. package/docs/packages/eslint-plugin.mdx +86 -0
  302. package/docs/packages/next.mdx +89 -0
  303. package/docs/packages/react.mdx +91 -0
  304. package/docs/packages/server.mdx +98 -0
  305. package/docs/packages/tauri.mdx +63 -0
  306. package/docs/packages/test.mdx +119 -0
  307. package/docs/packages/vite-plugin.mdx +94 -0
  308. package/docs/packages.mdx +218 -0
  309. package/docs/platform-integration.md +35 -26
  310. package/docs/predicates.mdx +248 -0
  311. package/docs/quickstart.mdx +257 -0
  312. package/docs/recipes.mdx +282 -0
  313. package/docs/skill-file.mdx +107 -0
  314. package/docs/state-management.mdx +199 -0
  315. package/docs/style.css +14 -0
  316. package/docs/system-map.md +50 -33
  317. package/docs/telemetry-contract.md +106 -52
  318. package/docs/telemetry-events.mdx +475 -0
  319. package/docs/telemetry.md +63 -55
  320. package/docs/testing.md +44 -6
  321. package/docs/token-efficiency.md +37 -27
  322. package/docs/tools-act-and-wait.mdx +173 -0
  323. package/docs/tools-act-sequence.mdx +137 -0
  324. package/docs/tools-act.mdx +95 -0
  325. package/docs/tools-assert.mdx +129 -0
  326. package/docs/tools-console.mdx +63 -0
  327. package/docs/tools-inspect.mdx +88 -0
  328. package/docs/tools-navigate.mdx +99 -0
  329. package/docs/tools-network.mdx +119 -0
  330. package/docs/tools-observe.mdx +145 -0
  331. package/docs/tools-overview.mdx +160 -0
  332. package/docs/tools-query.mdx +126 -0
  333. package/docs/tools-session-and-feedback.mdx +112 -0
  334. package/docs/tools-sessions.mdx +94 -0
  335. package/docs/tools-snapshot.mdx +129 -0
  336. package/docs/tools-state.mdx +104 -0
  337. package/docs/tools-tools-and-run.mdx +156 -0
  338. package/docs/tools-wait-for.mdx +85 -0
  339. package/docs/troubleshooting.mdx +263 -0
  340. package/docs/usage.md +209 -165
  341. package/docs/vs-chrome-devtools-mcp.mdx +123 -0
  342. package/docs/vs-playwright-mcp.mdx +125 -0
  343. package/docs/vs-screenshots.mdx +137 -0
  344. package/docs/what-is-reticle.mdx +63 -0
  345. package/docs/why-reticle.mdx +109 -0
  346. package/package.json +5 -5
  347. package/docs/desktop-apps.md +0 -256
  348. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  349. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  350. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  351. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  352. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  353. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  354. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  355. package/docs/matrix/MATRIX.md +0 -26
  356. package/docs/matrix/README.md +0 -59
  357. package/docs/mint.json +0 -61
@@ -0,0 +1,90 @@
1
+ /**
2
+ * "The app ignored this response" is only sayable once the app has had a chance to read it.
3
+ *
4
+ * `response-ignored` fires when a write succeeded on the server and nothing on the client moved. It
5
+ * is a real and valuable finding — a lost write, a response parsed into the void, a render that never
6
+ * happened. It is also an ACCUSATION, and it is decided by where a window happens to end.
7
+ *
8
+ * Waiting for in-flight requests before taking the verdict made that boundary dangerous in a way it
9
+ * was not before. Previously the write was still pending when the window closed, so it never reached
10
+ * the settled list and this rule could not fire at all. Now the response lands inside the window BY
11
+ * DESIGN — and the app's re-render happens in a later task, a few milliseconds after it. Close the
12
+ * window in that gap and every channel agrees that the app took a successful write and did nothing.
13
+ *
14
+ * The verdict is then `verified:"no"` on a completely correct application, produced entirely by where
15
+ * we chose to stop looking. For a verification tool that is the more damaging direction of error: a
16
+ * false green is a missed catch, but a false accusation sends someone to fix code that is not broken,
17
+ * and it is how the instrument stops being believed.
18
+ *
19
+ * So the response is not the end of the window — the app's REACTION to it is. The grace is paid only
20
+ * in the exact case that would otherwise be accused: a successful mutating write with nothing moved
21
+ * after it. Every other action returns without a single poll.
22
+ *
23
+ * Deliberately short. This covers a task-boundary re-render, not a slow app: if nothing has moved
24
+ * within the grace, "ignored" has become an honest description and the finding should stand.
25
+ */
26
+ import { EventType, MUTATING_METHODS } from '@reticlehq/core';
27
+ /**
28
+ * How long to let the app react. A React commit after a resolved fetch is one or two task hops; this
29
+ * is generous against that and still far too short to paper over an app that genuinely dropped the
30
+ * response.
31
+ */
32
+ const REACTION_GRACE_MS = 300;
33
+ /** Poll cadence — the same as the settle wait, for the same reason. */
34
+ const POLL_MS = 50;
35
+ /** Movement that would clear `response-ignored`; mirrors `uiAdvanced` in the detector. */
36
+ const MOVEMENT = new Set([
37
+ EventType.DOM_ADDED,
38
+ EventType.DOM_REMOVED,
39
+ EventType.DOM_ATTR,
40
+ EventType.DOM_TEXT,
41
+ EventType.STATE_CHANGE,
42
+ EventType.ROUTE_CHANGE,
43
+ ]);
44
+ function moved(events) {
45
+ return events.some((e) => MOVEMENT.has(e.type));
46
+ }
47
+ /**
48
+ * Is this a window that would be wrongly accused if it ended right now?
49
+ *
50
+ * True only for the accusable shape: a SUCCESSFUL MUTATING request settled, and nothing has moved. A
51
+ * read that changed nothing is a prefetch, a failed write is a different finding entirely, and an
52
+ * action that already moved the UI has nothing to wait for — none of them buy a grace period.
53
+ *
54
+ * "Mutating" is read from the same constant the accuser uses rather than restated here. Restating it
55
+ * is how `IPC` came to be absent from this list and present in that one, so on a desktop app every
56
+ * successful write skipped the grace and a correct app was told it had ignored its own response.
57
+ */
58
+ export function awaitsReaction(events) {
59
+ if (moved(events))
60
+ return false;
61
+ return events.some((e) => {
62
+ if (e.type !== EventType.NET_REQUEST)
63
+ return false;
64
+ const method = 'string' === typeof e.data['method'] ? e.data['method'].toUpperCase() : '';
65
+ const ok = e.data['ok'];
66
+ return MUTATING_METHODS.includes(method) && true === ok;
67
+ });
68
+ }
69
+ /**
70
+ * Wait, bounded by BOTH the grace and the caller's remaining budget, for the app to react to a write
71
+ * it has just been told succeeded.
72
+ *
73
+ * Returns whether the app moved. A `false` is not a verdict — it hands the question back to the
74
+ * contradiction engine, which will raise `response-ignored` exactly as it does today. The point is
75
+ * only that by then the app has been given its chance, so the finding means what it says.
76
+ */
77
+ export async function waitForReaction(session, since, budgetMs, opts) {
78
+ if (!awaitsReaction(session.eventsSince(since)))
79
+ return true;
80
+ const window = Math.min(REACTION_GRACE_MS, budgetMs);
81
+ if (window <= 0)
82
+ return false;
83
+ const deadline = session.elapsed() + window;
84
+ while (session.elapsed() < deadline) {
85
+ await opts.sleep(Math.min(POLL_MS, deadline - session.elapsed()));
86
+ if (moved(session.eventsSince(since)))
87
+ return true;
88
+ }
89
+ return false;
90
+ }
@@ -11,7 +11,7 @@ import { replayProgram } from '../flows/replay.js';
11
11
  import { diffLines } from '../project/baselines.js';
12
12
  import { selectPath, capDepth, projectComponentState } from '../session/state-select.js';
13
13
  import { costHint } from '../session/output-budget.js';
14
- import { buildReactionReport } from '../events/reaction.js';
14
+ import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
15
15
  import { asString, asNumber, parseInteractive } from './tools-helpers.js';
16
16
  import { sessionIdShape, commandOrThrow, snapshotTree } from './tool-kit.js';
17
17
  import { bufferEnvelope } from '../session/session-health.js';
@@ -39,6 +39,15 @@ function unanchoredWarning(count) {
39
39
  const HUD_LEVELS = ['info', 'warn', 'error'];
40
40
  const HUD_LEVEL_LIST = HUD_LEVELS.join(' | ');
41
41
  const hudLevelEnum = z.enum(HUD_LEVELS);
42
+ /**
43
+ * How deep an unscoped `reticle_state` read goes before collapsing to size markers.
44
+ *
45
+ * 3 keeps every store name and every top-level key visible — enough to see what exists and to name a
46
+ * `path` — while collapsing the collections that make an unscoped read enormous. Measured on the
47
+ * bench fixture: unbounded 10,119 B, depth 4 1,323 B, depth 3 596 B, depth 2 322 B. Two loses the
48
+ * per-store keys, which is the orientation the next call needs.
49
+ */
50
+ const DEFAULT_STATE_DEPTH = 1;
42
51
  export const READ_TOOLS = [
43
52
  {
44
53
  name: ReticleTool.BASELINE_SAVE,
@@ -178,17 +187,33 @@ export const READ_TOOLS = [
178
187
  const report = buildReactionReport(events, session.elapsed() - rec.cursor);
179
188
  // Self-generating oracles: propose ranked mustHold from what the recorded window actually did.
180
189
  const proposedConsequences = proposeConsequences(events);
181
- return Promise.resolve({
190
+ const digest = summarizeReaction(report);
191
+ // Say that the timeline was left out, and where it lives.
192
+ //
193
+ // Dropping the raw events is right: they were most of a response large enough to truncate the
194
+ // part that mattered. But a field that simply stops appearing is indistinguishable from one
195
+ // this version never had, and an agent cannot ask for something it does not know exists. The
196
+ // same rule the capped diff arrays follow: a trim is never silent.
197
+ const timeline = events.length > 0
198
+ ? {
199
+ timeline_omitted: `${String(events.length)} event(s) were recorded and are not included here. Call reticle_observe { since: ${String(rec.cursor)} } for the raw timeline.`,
200
+ }
201
+ : {};
202
+ const body = {
182
203
  recordingName: name,
183
204
  program,
205
+ ...timeline,
184
206
  ...(unstable > 0
185
207
  ? {
186
208
  warning: unanchoredWarning(unstable),
187
209
  }
188
210
  : {}),
189
211
  ...(proposedConsequences.length > 0 ? { proposedConsequences } : {}),
190
- ...report,
191
- cost: costHint(report, events.length),
212
+ ...digest,
213
+ };
214
+ return Promise.resolve({
215
+ ...body,
216
+ cost: costHint(body, events.length),
192
217
  });
193
218
  },
194
219
  },
@@ -379,7 +404,44 @@ export const READ_TOOLS = [
379
404
  return { ...root, storeNames: names };
380
405
  }
381
406
  if (path === undefined && depth === undefined) {
382
- return { ...root, storeNames: names }; // unchanged shape, safe storeNames
407
+ // An unscoped read used to hand back the WHOLE store tree: 10,119 bytes on this repo's own
408
+ // fixture. A tool result is not paid once — it stays in the conversation and is re-sent on
409
+ // every later turn, so one such read cost roughly 34,000 tokens across a 16-turn run.
410
+ //
411
+ // The VALUES are bounded; the ENVELOPE is not touched. `{ stores, storeNames, component }`
412
+ // is the documented shape and callers depend on it — a first attempt applied the bound by
413
+ // routing through the path selector, which silently changed the reply to `{ found, value }`
414
+ // and took the component projection and the bridge round-trip with it. Capping in place
415
+ // keeps every store name and the component read exactly where they were; only collections
416
+ // deeper than the bound collapse to a marker like "[Array(40)]", which still says what is
417
+ // there and how much of it.
418
+ //
419
+ // A default, not a cap: any explicit `depth` is honoured at any value, and `path` reads a
420
+ // sub-tree at full fidelity. Nothing became unreachable, only cheaper by default.
421
+ const stores = root.stores;
422
+ const bounded = 'object' === typeof stores && null !== stores
423
+ ? Object.fromEntries(Object.entries(stores).map(([k, v]) => [k, capDepth(v, DEFAULT_STATE_DEPTH)]))
424
+ : undefined;
425
+ // Say that it was bounded, and only when it actually was. A read that quietly hands back
426
+ // less than it was asked for is the shape of a false green: the agent sees
427
+ // `deployments: "[Array(40)]"`, concludes it has read the store, and asserts over a summary.
428
+ // The marker alone is suggestive; this makes it explicit and names the way to the real value.
429
+ const trimmed = bounded !== undefined && JSON.stringify(bounded) !== JSON.stringify(stores);
430
+ return {
431
+ ...root,
432
+ ...(bounded === undefined ? {} : { stores: bounded }),
433
+ storeNames: names,
434
+ ...(trimmed
435
+ ? {
436
+ truncation: {
437
+ note: `bounded to depth ${String(DEFAULT_STATE_DEPTH)} per store — this is NOT the ` +
438
+ 'whole store. Values shown as "[Array(n)]" or "{…n keys}" were collapsed; read ' +
439
+ 'one with { store, path } (e.g. { store:"app", path:"deployments.0" }) or pass ' +
440
+ 'an explicit `depth` for more.',
441
+ },
442
+ }
443
+ : {}),
444
+ };
383
445
  }
384
446
  // Back-compat: an older browser returned the whole store; scope it here (may already be
385
447
  // size-truncated in transit for a very large store — that is the limitation this fix removes
@@ -0,0 +1,33 @@
1
+ /** One candidate the browser returned for a target query. */
2
+ export interface TargetCandidate {
3
+ ref?: unknown;
4
+ role?: unknown;
5
+ name?: unknown;
6
+ visible?: unknown;
7
+ }
8
+ /** What the resolution produced: a ref to act on, or the reason there isn't one. */
9
+ export type TargetResolution = {
10
+ readonly kind: 'ref';
11
+ readonly ref: string;
12
+ } | {
13
+ readonly kind: 'error';
14
+ readonly message: string;
15
+ };
16
+ /**
17
+ * Turn a target QUERY into the single ref an action can be performed on.
18
+ *
19
+ * `act_and_wait` took only a `ref`, so every verification cost a `reticle_query` turn first just to
20
+ * learn one string. The advertised tool surface is re-sent on every turn, so that extra turn was not
21
+ * a small cost: measured on the wire, a two-turn verification spent 10,756 of its 11,235 tokens on
22
+ * schema and 479 on the actual answers. Removing the turn removes half the schema bill.
23
+ *
24
+ * Ambiguity is an ERROR, never a pick. Choosing "the first match" would let a verification act on a
25
+ * different element than the author meant and still report success, which is the false green this
26
+ * product exists to prevent — and the failure is invisible, because the verdict describes the
27
+ * element it DID act on. The message names the candidates so the caller can narrow rather than guess.
28
+ *
29
+ * Invisible matches are filtered before counting: a hidden duplicate is a common way for a
30
+ * legitimately unambiguous query to look ambiguous, and refusing on it would push callers back to
31
+ * the two-turn path for no gain.
32
+ */
33
+ export declare function resolveTargetRef(candidates: readonly unknown[]): TargetResolution;
@@ -0,0 +1,53 @@
1
+ import { asRecord } from './tools-helpers.js';
2
+ /**
3
+ * Turn a target QUERY into the single ref an action can be performed on.
4
+ *
5
+ * `act_and_wait` took only a `ref`, so every verification cost a `reticle_query` turn first just to
6
+ * learn one string. The advertised tool surface is re-sent on every turn, so that extra turn was not
7
+ * a small cost: measured on the wire, a two-turn verification spent 10,756 of its 11,235 tokens on
8
+ * schema and 479 on the actual answers. Removing the turn removes half the schema bill.
9
+ *
10
+ * Ambiguity is an ERROR, never a pick. Choosing "the first match" would let a verification act on a
11
+ * different element than the author meant and still report success, which is the false green this
12
+ * product exists to prevent — and the failure is invisible, because the verdict describes the
13
+ * element it DID act on. The message names the candidates so the caller can narrow rather than guess.
14
+ *
15
+ * Invisible matches are filtered before counting: a hidden duplicate is a common way for a
16
+ * legitimately unambiguous query to look ambiguous, and refusing on it would push callers back to
17
+ * the two-turn path for no gain.
18
+ */
19
+ export function resolveTargetRef(candidates) {
20
+ const all = candidates.map((c) => asRecord(c));
21
+ const visible = all.filter((c) => c.visible !== false);
22
+ const usable = visible.length > 0 ? visible : all;
23
+ if (0 === usable.length) {
24
+ return {
25
+ kind: 'error',
26
+ message: 'target matched no element. Nothing was acted on and no verdict is possible — widen the ' +
27
+ 'query, or take a reticle_snapshot to see what is actually on the page.',
28
+ };
29
+ }
30
+ if (usable.length > 1) {
31
+ const named = usable
32
+ .slice(0, 5)
33
+ .map((c) => {
34
+ const role = 'string' === typeof c.role ? c.role : '?';
35
+ const name = 'string' === typeof c.name ? c.name : '';
36
+ const ref = 'string' === typeof c.ref ? c.ref : '?';
37
+ return name.length > 0 ? `${ref} (${role} "${name}")` : `${ref} (${role})`;
38
+ })
39
+ .join(', ');
40
+ return {
41
+ kind: 'error',
42
+ message: `target matched ${String(usable.length)} elements and an action must not guess between ` +
43
+ `them: ${named}. Narrow the query (add role/name/testid or a scope), or pass an explicit ` +
44
+ '`ref` from reticle_query.',
45
+ };
46
+ }
47
+ const only = usable[0];
48
+ const ref = only === undefined ? undefined : only.ref;
49
+ if ('string' !== typeof ref || 0 === ref.length) {
50
+ return { kind: 'error', message: 'target matched an element with no usable ref.' };
51
+ }
52
+ return { kind: 'ref', ref };
53
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Spend the budget you were given before reporting that you ran out of certainty.
3
+ *
4
+ * `waitForPredicate` resolves the instant the predicate holds. On an app that navigates
5
+ * optimistically — which is normal, correct behaviour, and the norm on exactly the flows agents
6
+ * verify most: login, submit, save — that instant arrives while the write is still in flight. The
7
+ * verdict was then taken over a window the app had not finished writing, and came back
8
+ * `unknown / unsettled` with a recovery note asking the CALLER to re-check.
9
+ *
10
+ * Measured on the most common flow there is: a login that genuinely succeeded returned at 492ms out
11
+ * of an 8000ms budget. The declared signal had fired with matching data, state had moved from
12
+ * anonymous to authenticated, the token was in storage, honesty grade was `signal` and capture
13
+ * integrity was clean. Seven and a half seconds of authorised waiting went unused, and the agent was
14
+ * handed a shrug.
15
+ *
16
+ * A false NEGATIVE is not the mirror of a false positive here. A false positive stops an agent early;
17
+ * a false negative makes it redo work that already succeeded, and — far worse — teaches it to
18
+ * discount the verdict channel. The verdict channel is the entire product, and an agent that learns
19
+ * to ignore `unknown` will ignore the true ones too.
20
+ *
21
+ * What this does NOT do is decide the request succeeded. At 492ms nobody can know that, and inventing
22
+ * a green here would be the exact defect this product exists to prevent. It waits for the answer
23
+ * rather than guessing at it:
24
+ *
25
+ * - everything settles → the normal contradiction machinery sees the real responses, so a failure
26
+ * still contradicts and a success no longer looks like an absence;
27
+ * - still in flight when the budget genuinely runs out → `unsettled`, as before, but now an honest
28
+ * limit instead of an early exit.
29
+ */
30
+ /** Just the shape this needs — a real Session satisfies it, and a test can supply it. */
31
+ export interface SettleSource {
32
+ eventsSince(cursor: number): readonly {
33
+ type: string;
34
+ data: Record<string, unknown>;
35
+ }[];
36
+ elapsed(): number;
37
+ }
38
+ interface WaitOpts {
39
+ /** Injected so the wait is testable without real time passing (rule: never call the clock inline). */
40
+ sleep(ms: number): Promise<void>;
41
+ }
42
+ /**
43
+ * Requests that started in this window and have not come back.
44
+ *
45
+ * A settle for a request this window never saw START is ignored on purpose: the window is floored at
46
+ * the act cursor, so a response belonging to an earlier action can land inside it, and counting that
47
+ * as progress would let unrelated traffic close the window early.
48
+ */
49
+ export declare function inFlightRequestIds(events: readonly {
50
+ type: string;
51
+ data: Record<string, unknown>;
52
+ }[]): string[];
53
+ /**
54
+ * The same outstanding requests, as "METHOD url" — what an unsettled verdict has to be able to name.
55
+ *
56
+ * A correlation id is the right key for the wait and useless in a sentence: an agent told that
57
+ * `r-17` is open cannot act on it, while `POST /api/login` is the thing to re-check or to assert
58
+ * directly. Read off the NET_PENDING the window already holds, so this costs one pass over events
59
+ * that are in hand.
60
+ */
61
+ export declare function inFlightRequestLabels(events: readonly {
62
+ type: string;
63
+ data: Record<string, unknown>;
64
+ }[]): string[];
65
+ /**
66
+ * Endpoints called MORE THAN ONCE in this window, busiest first, as "METHOD url ×N".
67
+ *
68
+ * The other half of an honest `unsettled`. A retrying query against a dead backend leaves nothing in
69
+ * flight — every attempt comes back, then the next one fires — so the verdict fell through to "what
70
+ * kept the page busy was its own churn (a poll, a timer, an animation)", which names three things
71
+ * and points at none of them. Measured in the field: on an app with a background retry loop
72
+ * `act_and_wait` could never return a pass, and nothing in the verdict said which endpoint to look
73
+ * at. This does not judge the repetition — a poll is legitimate — it reports it, which is all an
74
+ * agent needs to tell "my assertion was wrong" from "this app never quiets".
75
+ */
76
+ export declare function repeatedRequestLabels(events: readonly {
77
+ type: string;
78
+ data: Record<string, unknown>;
79
+ }[]): string[];
80
+ export interface SettleResult {
81
+ /** True when nothing this window started is still outstanding. */
82
+ settled: boolean;
83
+ /** What was still open when the wait ended — empty when `settled`. */
84
+ stillInFlight: string[];
85
+ }
86
+ /**
87
+ * Wait, bounded by `budgetMs`, for the requests this window started to come back.
88
+ *
89
+ * Returns immediately when nothing is in flight, so the common green path — an action with no traffic
90
+ * — pays nothing at all. A zero or negative budget means no wait: the caller may have spent the whole
91
+ * timeout reaching the predicate, and a negative deadline must mean "stop", never "loop forever".
92
+ */
93
+ export declare function waitForInFlight(session: SettleSource, since: number, budgetMs: number, opts: WaitOpts): Promise<SettleResult>;
94
+ export {};
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Spend the budget you were given before reporting that you ran out of certainty.
3
+ *
4
+ * `waitForPredicate` resolves the instant the predicate holds. On an app that navigates
5
+ * optimistically — which is normal, correct behaviour, and the norm on exactly the flows agents
6
+ * verify most: login, submit, save — that instant arrives while the write is still in flight. The
7
+ * verdict was then taken over a window the app had not finished writing, and came back
8
+ * `unknown / unsettled` with a recovery note asking the CALLER to re-check.
9
+ *
10
+ * Measured on the most common flow there is: a login that genuinely succeeded returned at 492ms out
11
+ * of an 8000ms budget. The declared signal had fired with matching data, state had moved from
12
+ * anonymous to authenticated, the token was in storage, honesty grade was `signal` and capture
13
+ * integrity was clean. Seven and a half seconds of authorised waiting went unused, and the agent was
14
+ * handed a shrug.
15
+ *
16
+ * A false NEGATIVE is not the mirror of a false positive here. A false positive stops an agent early;
17
+ * a false negative makes it redo work that already succeeded, and — far worse — teaches it to
18
+ * discount the verdict channel. The verdict channel is the entire product, and an agent that learns
19
+ * to ignore `unknown` will ignore the true ones too.
20
+ *
21
+ * What this does NOT do is decide the request succeeded. At 492ms nobody can know that, and inventing
22
+ * a green here would be the exact defect this product exists to prevent. It waits for the answer
23
+ * rather than guessing at it:
24
+ *
25
+ * - everything settles → the normal contradiction machinery sees the real responses, so a failure
26
+ * still contradicts and a success no longer looks like an absence;
27
+ * - still in flight when the budget genuinely runs out → `unsettled`, as before, but now an honest
28
+ * limit instead of an early exit.
29
+ */
30
+ import { EventType, isDevToolingUrl } from '@reticlehq/core';
31
+ /**
32
+ * The dev toolchain talking about ITSELF is not the app finishing its work.
33
+ *
34
+ * `evalSettled` and `findContradictions` both drop it already; this file read the raw window, so the
35
+ * exclusion was cosmetic exactly where it decides a verdict. Reported from a real drive: a
36
+ * deliberately disabled control — no state change, no storage change, zero application requests —
37
+ * was graded on the strength of a Next webpack hot-update the same payload printed as ignored.
38
+ */
39
+ const isDevTooling = (data) => isDevToolingUrl('string' === typeof data['url'] ? data['url'] : undefined);
40
+ /** How often to re-read the buffer. Short enough to return promptly, long enough not to spin. */
41
+ const POLL_MS = 50;
42
+ const idOf = (data) => 'string' === typeof data['id'] ? data['id'] : undefined;
43
+ /**
44
+ * Requests that started in this window and have not come back.
45
+ *
46
+ * A settle for a request this window never saw START is ignored on purpose: the window is floored at
47
+ * the act cursor, so a response belonging to an earlier action can land inside it, and counting that
48
+ * as progress would let unrelated traffic close the window early.
49
+ */
50
+ export function inFlightRequestIds(events) {
51
+ const settled = new Set();
52
+ for (const e of events) {
53
+ if (e.type !== EventType.NET_REQUEST)
54
+ continue;
55
+ const id = idOf(e.data);
56
+ if (id !== undefined)
57
+ settled.add(id);
58
+ }
59
+ const open = [];
60
+ for (const e of events) {
61
+ if (e.type !== EventType.NET_PENDING || isDevTooling(e.data))
62
+ continue;
63
+ const id = idOf(e.data);
64
+ if (id !== undefined && !settled.has(id) && !open.includes(id))
65
+ open.push(id);
66
+ }
67
+ return open;
68
+ }
69
+ /**
70
+ * The same outstanding requests, as "METHOD url" — what an unsettled verdict has to be able to name.
71
+ *
72
+ * A correlation id is the right key for the wait and useless in a sentence: an agent told that
73
+ * `r-17` is open cannot act on it, while `POST /api/login` is the thing to re-check or to assert
74
+ * directly. Read off the NET_PENDING the window already holds, so this costs one pass over events
75
+ * that are in hand.
76
+ */
77
+ export function inFlightRequestLabels(events) {
78
+ const open = new Set(inFlightRequestIds(events));
79
+ const labels = [];
80
+ for (const e of events) {
81
+ if (e.type !== EventType.NET_PENDING)
82
+ continue;
83
+ const id = idOf(e.data);
84
+ if (id === undefined || !open.has(id))
85
+ continue;
86
+ open.delete(id); // one label per request, however many times its start was recorded
87
+ const method = 'string' === typeof e.data['method'] ? e.data['method'] : 'request';
88
+ const url = 'string' === typeof e.data['url'] ? e.data['url'] : 'an unreported url';
89
+ labels.push(`${method} ${url}`);
90
+ }
91
+ return labels;
92
+ }
93
+ /** Enough to recognise the loop; a busy dashboard must not spend the verdict on a URL list. */
94
+ const MAX_REPEATED = 3;
95
+ /** One call is traffic. Two to the same endpoint inside one window is a repeat. */
96
+ const MIN_REPEATS = 2;
97
+ /**
98
+ * Endpoints called MORE THAN ONCE in this window, busiest first, as "METHOD url ×N".
99
+ *
100
+ * The other half of an honest `unsettled`. A retrying query against a dead backend leaves nothing in
101
+ * flight — every attempt comes back, then the next one fires — so the verdict fell through to "what
102
+ * kept the page busy was its own churn (a poll, a timer, an animation)", which names three things
103
+ * and points at none of them. Measured in the field: on an app with a background retry loop
104
+ * `act_and_wait` could never return a pass, and nothing in the verdict said which endpoint to look
105
+ * at. This does not judge the repetition — a poll is legitimate — it reports it, which is all an
106
+ * agent needs to tell "my assertion was wrong" from "this app never quiets".
107
+ */
108
+ export function repeatedRequestLabels(events) {
109
+ const counts = new Map();
110
+ for (const e of events) {
111
+ if (e.type !== EventType.NET_REQUEST || isDevTooling(e.data))
112
+ continue;
113
+ const method = 'string' === typeof e.data['method'] ? e.data['method'] : 'request';
114
+ const url = 'string' === typeof e.data['url'] ? e.data['url'] : 'an unreported url';
115
+ const key = `${method} ${url}`;
116
+ counts.set(key, (counts.get(key) ?? 0) + 1);
117
+ }
118
+ return [...counts.entries()]
119
+ .filter(([, count]) => count >= MIN_REPEATS)
120
+ .sort(([, a], [, b]) => b - a)
121
+ .slice(0, MAX_REPEATED)
122
+ .map(([key, count]) => `${key} ×${String(count)}`);
123
+ }
124
+ /**
125
+ * Wait, bounded by `budgetMs`, for the requests this window started to come back.
126
+ *
127
+ * Returns immediately when nothing is in flight, so the common green path — an action with no traffic
128
+ * — pays nothing at all. A zero or negative budget means no wait: the caller may have spent the whole
129
+ * timeout reaching the predicate, and a negative deadline must mean "stop", never "loop forever".
130
+ */
131
+ export async function waitForInFlight(session, since, budgetMs, opts) {
132
+ let open = inFlightRequestIds(session.eventsSince(since));
133
+ if (0 === open.length)
134
+ return { settled: true, stillInFlight: [] };
135
+ if (budgetMs <= 0)
136
+ return { settled: false, stillInFlight: open };
137
+ const deadline = session.elapsed() + budgetMs;
138
+ while (session.elapsed() < deadline) {
139
+ // Never overshoot the budget: the last sleep is trimmed to whatever is actually left.
140
+ await opts.sleep(Math.min(POLL_MS, deadline - session.elapsed()));
141
+ open = inFlightRequestIds(session.eventsSince(since));
142
+ if (0 === open.length)
143
+ return { settled: true, stillInFlight: [] };
144
+ }
145
+ return { settled: false, stillInFlight: open };
146
+ }
@@ -21,16 +21,24 @@ export type SnapshotDeltaMode = (typeof SnapshotDeltaMode)[keyof typeof Snapshot
21
21
  interface SnapshotDelta {
22
22
  added: string[];
23
23
  removed: string[];
24
+ changed: string[];
24
25
  addedCount: number;
25
26
  removedCount: number;
26
27
  }
28
+ /** Where focus moved, when it moved. Absent fields mean nothing held focus on that side. */
29
+ export interface FocusChange {
30
+ from?: string;
31
+ to?: string;
32
+ }
27
33
  type DeltaDecision = {
28
34
  mode: typeof SnapshotDeltaMode.FULL;
29
35
  } | {
30
36
  mode: typeof SnapshotDeltaMode.UNCHANGED;
37
+ focusChanged?: FocusChange;
31
38
  } | {
32
39
  mode: typeof SnapshotDeltaMode.DELTA;
33
40
  delta: SnapshotDelta;
41
+ focusChanged?: FocusChange;
34
42
  };
35
43
  /** Pure: decide full vs delta vs unchanged given the previous tree (same route) and the next tree. */
36
44
  export declare function snapshotDelta(prevTree: string | undefined, nextTree: string): DeltaDecision;
@@ -38,6 +46,8 @@ export declare function snapshotDelta(prevTree: string | undefined, nextTree: st
38
46
  export declare class SnapshotCache {
39
47
  #private;
40
48
  constructor(max?: number);
49
+ /** True when an entry exists for this key (regardless of route match). */
50
+ has(key: string): boolean;
41
51
  /** Last tree for this key IF the route still matches; undefined when absent or route changed. */
42
52
  recall(key: string, route: string): string | undefined;
43
53
  remember(key: string, route: string, tree: string): void;