@reticlehq/server 2.12.0 → 2.13.1

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 (386) hide show
  1. package/SKILL.md +67 -109
  2. package/dist/bridge/auth-failure-reason.d.ts +6 -2
  3. package/dist/bridge/auth-failure-reason.js +15 -8
  4. package/dist/bridge/bridge.d.ts +18 -1
  5. package/dist/bridge/bridge.js +247 -164
  6. package/dist/bridge/pairing-token.d.ts +5 -0
  7. package/dist/bridge/pairing-token.js +28 -0
  8. package/dist/bridge/protocol-skew.d.ts +37 -0
  9. package/dist/bridge/protocol-skew.js +45 -0
  10. package/dist/capsule/capsule-store.d.ts +6 -6
  11. package/dist/capsule/causal-summary.js +7 -2
  12. package/dist/cli/cli-doctor.js +31 -1
  13. package/dist/cli/cli-launch.d.ts +11 -1
  14. package/dist/cli/cli-launch.js +59 -10
  15. package/dist/cli/cli-parse.d.ts +15 -1
  16. package/dist/cli/cli-parse.js +212 -8
  17. package/dist/cli/cli-verify.d.ts +1 -0
  18. package/dist/cli/cli-verify.js +3 -2
  19. package/dist/cli/cloud-cli.js +428 -259
  20. package/dist/cli/cloud-keystore.d.ts +37 -0
  21. package/dist/cli/cloud-keystore.js +97 -0
  22. package/dist/cli/cloud-kit.d.ts +37 -0
  23. package/dist/cli/cloud-kit.js +121 -0
  24. package/dist/cli/cloud-login.d.ts +17 -0
  25. package/dist/cli/cloud-login.js +183 -0
  26. package/dist/cli/cloud-session.d.ts +48 -0
  27. package/dist/cli/cloud-session.js +53 -0
  28. package/dist/cli/config-discovery.js +1 -0
  29. package/dist/cli/daemon-start-options.d.ts +32 -0
  30. package/dist/cli/daemon-start-options.js +38 -0
  31. package/dist/cli/device-flow.d.ts +76 -0
  32. package/dist/cli/device-flow.js +56 -0
  33. package/dist/cli/doctor-rows.d.ts +9 -0
  34. package/dist/cli/doctor-rows.js +9 -0
  35. package/dist/cli/drive-attach.d.ts +13 -0
  36. package/dist/cli/drive-attach.js +18 -0
  37. package/dist/cli/drive-command.js +7 -3
  38. package/dist/cli/mcp-command.js +51 -20
  39. package/dist/cli/project-name.d.ts +30 -0
  40. package/dist/cli/project-name.js +44 -0
  41. package/dist/cli/status-next-action.d.ts +14 -0
  42. package/dist/cli/status-next-action.js +4 -4
  43. package/dist/cli.d.ts +1 -0
  44. package/dist/cli.js +134 -80
  45. package/dist/cloud/cloud-config.d.ts +22 -1
  46. package/dist/cloud/cloud-config.js +64 -5
  47. package/dist/cloud/cloud-sync.d.ts +5 -5
  48. package/dist/cloud/cloud-sync.js +33 -2
  49. package/dist/cloud/sync-cycle.d.ts +110 -0
  50. package/dist/cloud/sync-cycle.js +270 -0
  51. package/dist/cloud/sync-daemon.d.ts +69 -0
  52. package/dist/cloud/sync-daemon.js +260 -0
  53. package/dist/cloud/sync-disk.d.ts +9 -0
  54. package/dist/cloud/sync-disk.js +191 -0
  55. package/dist/cloud/sync-hash.d.ts +2 -0
  56. package/dist/cloud/sync-hash.js +35 -0
  57. package/dist/crawl/crawl-tools.js +5 -3
  58. package/dist/crawl/crawl.d.ts +3 -0
  59. package/dist/crawl/crawl.js +5 -0
  60. package/dist/daemon/daemon-resolve.d.ts +43 -0
  61. package/dist/daemon/daemon-resolve.js +69 -0
  62. package/dist/daemon/dev-servers.d.ts +15 -0
  63. package/dist/daemon/dev-servers.js +68 -0
  64. package/dist/daemon/wake-decision.d.ts +32 -0
  65. package/dist/daemon/wake-decision.js +38 -0
  66. package/dist/domain/domain-tools.js +2 -2
  67. package/dist/events/contradictions.d.ts +18 -0
  68. package/dist/events/contradictions.js +134 -21
  69. package/dist/events/declared.d.ts +7 -4
  70. package/dist/events/declared.js +15 -6
  71. package/dist/events/echo-mismatch.js +64 -14
  72. package/dist/events/event-filters.d.ts +8 -0
  73. package/dist/events/event-filters.js +19 -2
  74. package/dist/events/predicate-eval.js +53 -5
  75. package/dist/events/predicate-precheck.d.ts +8 -0
  76. package/dist/events/predicate-precheck.js +41 -0
  77. package/dist/events/predicate-route.d.ts +14 -0
  78. package/dist/events/predicate-route.js +36 -8
  79. package/dist/events/predicate.d.ts +5 -0
  80. package/dist/events/predicate.js +37 -5
  81. package/dist/events/split-text-miss.d.ts +30 -0
  82. package/dist/events/split-text-miss.js +44 -0
  83. package/dist/flows/flake.d.ts +6 -6
  84. package/dist/flows/flow-memory-consult.d.ts +55 -0
  85. package/dist/flows/flow-memory-consult.js +38 -0
  86. package/dist/flows/flow-replay-run.d.ts +18 -7
  87. package/dist/flows/flow-replay-run.js +239 -22
  88. package/dist/flows/flow-replay.js +11 -2
  89. package/dist/flows/flow-step-runners.d.ts +8 -0
  90. package/dist/flows/flow-step-runners.js +8 -2
  91. package/dist/flows/flow-store-for-session.d.ts +31 -0
  92. package/dist/flows/flow-store-for-session.js +33 -0
  93. package/dist/flows/flow-tools.js +58 -32
  94. package/dist/flows/flows.d.ts +8 -0
  95. package/dist/flows/flows.js +468 -2
  96. package/dist/flows/heal-run.js +5 -2
  97. package/dist/flows/predicate-to-expect.d.ts +17 -12
  98. package/dist/flows/predicate-to-expect.js +30 -12
  99. package/dist/flows/recordings.d.ts +13 -1
  100. package/dist/flows/recordings.js +17 -2
  101. package/dist/flows/replay.d.ts +22 -2
  102. package/dist/flows/replay.js +28 -2
  103. package/dist/flows/verify-change-tools.js +5 -2
  104. package/dist/honesty/blind-spots.d.ts +55 -2
  105. package/dist/honesty/blind-spots.js +63 -3
  106. package/dist/honesty/honesty.d.ts +24 -0
  107. package/dist/honesty/honesty.js +25 -1
  108. package/dist/honesty/instrumentation-gaps.d.ts +9 -0
  109. package/dist/honesty/instrumentation-gaps.js +29 -2
  110. package/dist/honesty/verified.d.ts +2 -1
  111. package/dist/honesty/verified.js +16 -1
  112. package/dist/http-server.js +12 -0
  113. package/dist/impact/impact-recorder.d.ts +31 -8
  114. package/dist/impact/impact-recorder.js +104 -21
  115. package/dist/impact/impact-store.d.ts +15 -1
  116. package/dist/impact/impact-store.js +43 -2
  117. package/dist/index.js +113 -7
  118. package/dist/init/agent-rules.d.ts +13 -6
  119. package/dist/init/agent-rules.js +23 -12
  120. package/dist/init/astro-patch.d.ts +16 -0
  121. package/dist/init/astro-patch.js +28 -0
  122. package/dist/init/capabilities.d.ts +4 -1
  123. package/dist/init/capabilities.js +22 -2
  124. package/dist/init/confirm.d.ts +31 -7
  125. package/dist/init/confirm.js +48 -8
  126. package/dist/init/cra.d.ts +20 -4
  127. package/dist/init/cra.js +57 -17
  128. package/dist/init/csp-check.js +17 -2
  129. package/dist/init/csp-doctor.d.ts +8 -1
  130. package/dist/init/csp-doctor.js +8 -3
  131. package/dist/init/dev-script.d.ts +3 -1
  132. package/dist/init/dev-script.js +8 -5
  133. package/dist/init/format-generated.d.ts +18 -0
  134. package/dist/init/format-generated.js +39 -0
  135. package/dist/init/install-hint.d.ts +9 -0
  136. package/dist/init/install-hint.js +29 -0
  137. package/dist/init/mcp-clients.d.ts +7 -0
  138. package/dist/init/mcp-clients.js +7 -0
  139. package/dist/init/mcp.d.ts +1 -1
  140. package/dist/init/mcp.js +1 -1
  141. package/dist/init/node-io.js +11 -1
  142. package/dist/init/plan-framework.js +81 -45
  143. package/dist/init/plan.d.ts +20 -7
  144. package/dist/init/plan.js +20 -45
  145. package/dist/init/preflight.d.ts +28 -0
  146. package/dist/init/preflight.js +38 -0
  147. package/dist/init/run.d.ts +32 -9
  148. package/dist/init/run.js +141 -119
  149. package/dist/init/slash-command.d.ts +1 -1
  150. package/dist/init/snippets.js +41 -25
  151. package/dist/init/vite-config.d.ts +1 -1
  152. package/dist/init/vite-config.js +39 -25
  153. package/dist/init/workspace-apps.d.ts +17 -10
  154. package/dist/init/workspace-apps.js +110 -0
  155. package/dist/input/network-mock-tools.js +3 -6
  156. package/dist/input/real-input.js +25 -5
  157. package/dist/input/scroll-tools.js +4 -6
  158. package/dist/input/viewport-tools.js +5 -4
  159. package/dist/intent/inline-intent.d.ts +10 -19
  160. package/dist/intent/inline-intent.js +91 -1
  161. package/dist/intent/intent-shard-store.d.ts +72 -0
  162. package/dist/intent/intent-shard-store.js +203 -0
  163. package/dist/intent/intent-shard.d.ts +411 -0
  164. package/dist/intent/intent-shard.js +176 -0
  165. package/dist/intent/intent-store.d.ts +12 -0
  166. package/dist/intent/intent-store.js +21 -0
  167. package/dist/intent/intent-subject.d.ts +45 -0
  168. package/dist/intent/intent-subject.js +177 -0
  169. package/dist/intent/intent-tools.js +65 -2
  170. package/dist/intent/open-intents.d.ts +21 -0
  171. package/dist/intent/open-intents.js +15 -0
  172. package/dist/license/license-env.d.ts +29 -0
  173. package/dist/license/license-env.js +148 -0
  174. package/dist/loopback-agent.d.ts +31 -0
  175. package/dist/loopback-agent.js +57 -0
  176. package/dist/mcp/attach-memory.d.ts +18 -2
  177. package/dist/mcp/attach-memory.js +50 -7
  178. package/dist/mcp/client-identity.d.ts +31 -0
  179. package/dist/mcp/client-identity.js +51 -0
  180. package/dist/mcp/mcp-post-transport.d.ts +26 -0
  181. package/dist/mcp/mcp-post-transport.js +109 -0
  182. package/dist/mcp/mcp-proxy.d.ts +2 -1
  183. package/dist/mcp/mcp-proxy.js +52 -62
  184. package/dist/mcp/mcp.js +7 -1
  185. package/dist/mcp/proxy-backoff.d.ts +11 -0
  186. package/dist/mcp/proxy-backoff.js +13 -0
  187. package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
  188. package/dist/mcp/proxy-daemon-probe.js +7 -1
  189. package/dist/mcp/proxy-handshake.d.ts +23 -0
  190. package/dist/mcp/proxy-handshake.js +31 -0
  191. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  192. package/dist/mcp/tool-catalog-cache.js +12 -0
  193. package/dist/memory/memory-tools.d.ts +2 -0
  194. package/dist/memory/memory-tools.js +82 -0
  195. package/dist/memory/project-memory.d.ts +34 -0
  196. package/dist/memory/project-memory.js +96 -0
  197. package/dist/oracles/propose-consequences.js +12 -3
  198. package/dist/pool/browser-pool.d.ts +63 -4
  199. package/dist/pool/browser-pool.js +113 -4
  200. package/dist/pool/playwright-launcher.js +9 -0
  201. package/dist/project/project-tools.js +3 -3
  202. package/dist/project/reticle-dir.d.ts +15 -2
  203. package/dist/project/reticle-dir.js +21 -4
  204. package/dist/runs/run-store.d.ts +10 -0
  205. package/dist/runs/run-store.js +10 -0
  206. package/dist/runs/verification-sync.d.ts +0 -11
  207. package/dist/runs/verification-sync.js +22 -1
  208. package/dist/runs/verify-server.d.ts +8 -1
  209. package/dist/runs/verify-server.js +14 -2
  210. package/dist/session/command-timeout.d.ts +5 -2
  211. package/dist/session/command-timeout.js +5 -4
  212. package/dist/session/lease-visibility.d.ts +67 -0
  213. package/dist/session/lease-visibility.js +76 -0
  214. package/dist/session/no-session-diagnosis.d.ts +23 -0
  215. package/dist/session/no-session-diagnosis.js +58 -17
  216. package/dist/session/no-session-next-action.d.ts +7 -0
  217. package/dist/session/no-session-next-action.js +42 -3
  218. package/dist/session/no-session-watch.d.ts +10 -6
  219. package/dist/session/no-session-watch.js +51 -19
  220. package/dist/session/pending-commands.d.ts +11 -1
  221. package/dist/session/pending-commands.js +12 -2
  222. package/dist/session/resolve-within.d.ts +34 -0
  223. package/dist/session/resolve-within.js +53 -0
  224. package/dist/session/session-info.d.ts +18 -0
  225. package/dist/session/session-info.js +11 -0
  226. package/dist/session/session-manager.d.ts +14 -0
  227. package/dist/session/session-manager.js +61 -3
  228. package/dist/session/session-mirror.d.ts +16 -0
  229. package/dist/session/session-mirror.js +22 -0
  230. package/dist/session/session-recommendation.d.ts +19 -5
  231. package/dist/session/session-recommendation.js +25 -9
  232. package/dist/session/session-successor.js +36 -1
  233. package/dist/session/session-tools.js +2 -2
  234. package/dist/session/session.d.ts +22 -2
  235. package/dist/session/session.js +87 -7
  236. package/dist/session/wall-clock.d.ts +12 -0
  237. package/dist/session/wall-clock.js +14 -0
  238. package/dist/setup/agent-configs.d.ts +96 -0
  239. package/dist/setup/agent-configs.js +333 -0
  240. package/dist/setup/agent-io.d.ts +9 -0
  241. package/dist/setup/agent-io.js +16 -0
  242. package/dist/setup/agent-writer.d.ts +51 -0
  243. package/dist/setup/agent-writer.js +115 -0
  244. package/dist/setup/approval-migration.d.ts +47 -0
  245. package/dist/setup/approval-migration.js +68 -0
  246. package/dist/setup/auto-approve.d.ts +85 -0
  247. package/dist/setup/auto-approve.js +150 -0
  248. package/dist/setup/bridge-port.d.ts +15 -0
  249. package/dist/setup/bridge-port.js +24 -0
  250. package/dist/setup/desktop-shape.d.ts +57 -0
  251. package/dist/setup/desktop-shape.js +63 -0
  252. package/dist/setup/dev-server-wait.d.ts +68 -0
  253. package/dist/setup/dev-server-wait.js +67 -0
  254. package/dist/setup/drive-agent.d.ts +71 -0
  255. package/dist/setup/drive-agent.js +171 -0
  256. package/dist/setup/drive-plan.d.ts +68 -0
  257. package/dist/setup/drive-plan.js +130 -0
  258. package/dist/setup/ensure-daemon.d.ts +29 -0
  259. package/dist/setup/ensure-daemon.js +58 -0
  260. package/dist/setup/init-runtime.d.ts +47 -0
  261. package/dist/setup/init-runtime.js +160 -0
  262. package/dist/setup/interrupt.d.ts +25 -0
  263. package/dist/setup/interrupt.js +40 -0
  264. package/dist/setup/license-key.d.ts +39 -0
  265. package/dist/setup/license-key.js +72 -0
  266. package/dist/setup/listeners.d.ts +37 -0
  267. package/dist/setup/listeners.js +85 -0
  268. package/dist/setup/node-effects.d.ts +60 -0
  269. package/dist/setup/node-effects.js +210 -0
  270. package/dist/setup/page-probe.d.ts +39 -0
  271. package/dist/setup/page-probe.js +49 -0
  272. package/dist/setup/relaunch.d.ts +36 -0
  273. package/dist/setup/relaunch.js +57 -0
  274. package/dist/setup/remaining-steps.d.ts +19 -0
  275. package/dist/setup/remaining-steps.js +31 -0
  276. package/dist/setup/run-setup.d.ts +99 -0
  277. package/dist/setup/run-setup.js +203 -0
  278. package/dist/setup/session-pick.d.ts +47 -0
  279. package/dist/setup/session-pick.js +51 -0
  280. package/dist/setup/setup-command.d.ts +45 -0
  281. package/dist/setup/setup-command.js +199 -0
  282. package/dist/setup/setup-options.d.ts +74 -0
  283. package/dist/setup/setup-options.js +67 -0
  284. package/dist/setup/transcripts.d.ts +16 -0
  285. package/dist/setup/transcripts.js +43 -0
  286. package/dist/status-payload.d.ts +14 -1
  287. package/dist/status-payload.js +23 -1
  288. package/dist/telemetry/daemon-telemetry.d.ts +8 -1
  289. package/dist/telemetry/daemon-telemetry.js +52 -5
  290. package/dist/telemetry/error-class.js +1 -1
  291. package/dist/telemetry/feedback-context.d.ts +9 -1
  292. package/dist/telemetry/feedback-context.js +60 -29
  293. package/dist/telemetry/feedback-local.d.ts +15 -0
  294. package/dist/telemetry/feedback-local.js +56 -0
  295. package/dist/telemetry/feedback.d.ts +11 -0
  296. package/dist/telemetry/feedback.js +19 -6
  297. package/dist/telemetry/init-telemetry.d.ts +2 -0
  298. package/dist/telemetry/init-telemetry.js +2 -0
  299. package/dist/telemetry/install-source.d.ts +14 -0
  300. package/dist/telemetry/install-source.js +30 -0
  301. package/dist/telemetry/license-activation.d.ts +7 -1
  302. package/dist/telemetry/license-activation.js +9 -5
  303. package/dist/telemetry/mcp-connection.js +7 -0
  304. package/dist/telemetry/project-profile.d.ts +12 -1
  305. package/dist/telemetry/project-profile.js +11 -6
  306. package/dist/telemetry/proxy-telemetry.d.ts +3 -0
  307. package/dist/telemetry/proxy-telemetry.js +21 -0
  308. package/dist/telemetry/session-metrics.d.ts +12 -0
  309. package/dist/telemetry/session-metrics.js +35 -1
  310. package/dist/telemetry/telemetry.js +17 -2
  311. package/dist/telemetry/tool-refused.d.ts +3 -1
  312. package/dist/telemetry/tool-refused.js +34 -2
  313. package/dist/tools/act-capsule.d.ts +10 -0
  314. package/dist/tools/act-capsule.js +8 -2
  315. package/dist/tools/act-danger.d.ts +2 -1
  316. package/dist/tools/act-danger.js +11 -3
  317. package/dist/tools/act-observation.d.ts +41 -0
  318. package/dist/tools/act-observation.js +36 -0
  319. package/dist/tools/act-preflight.d.ts +20 -0
  320. package/dist/tools/act-preflight.js +98 -0
  321. package/dist/tools/act-sequence-retry.d.ts +45 -0
  322. package/dist/tools/act-sequence-retry.js +58 -0
  323. package/dist/tools/act-sequence-tool.d.ts +13 -0
  324. package/dist/tools/act-sequence-tool.js +135 -0
  325. package/dist/tools/act-target.d.ts +14 -0
  326. package/dist/tools/act-target.js +47 -0
  327. package/dist/tools/act-tools.d.ts +21 -1
  328. package/dist/tools/act-tools.js +100 -213
  329. package/dist/tools/assert-verdict.js +22 -16
  330. package/dist/tools/browser-tools.js +6 -2
  331. package/dist/tools/dynamic-tools.js +24 -5
  332. package/dist/tools/error-recovery.d.ts +1 -0
  333. package/dist/tools/error-recovery.js +8 -0
  334. package/dist/tools/invoke-tool.js +59 -9
  335. package/dist/tools/lease-tools.d.ts +30 -4
  336. package/dist/tools/lease-tools.js +169 -5
  337. package/dist/tools/merged-name-redirect.d.ts +13 -0
  338. package/dist/tools/merged-name-redirect.js +22 -0
  339. package/dist/tools/numeric-bounds.d.ts +100 -0
  340. package/dist/tools/numeric-bounds.js +125 -0
  341. package/dist/tools/observe-tools.js +47 -40
  342. package/dist/tools/read-tools.js +28 -7
  343. package/dist/tools/real-input-attempt.d.ts +10 -0
  344. package/dist/tools/real-input-attempt.js +59 -1
  345. package/dist/tools/reconcile-tools.js +4 -4
  346. package/dist/tools/tool-kit.d.ts +12 -0
  347. package/dist/tools/tool-kit.js +6 -1
  348. package/dist/tools/tool-names.d.ts +2 -0
  349. package/dist/tools/tool-names.js +2 -0
  350. package/dist/tools/tools-helpers.d.ts +19 -0
  351. package/dist/tools/tools-helpers.js +46 -0
  352. package/dist/tools/tools.d.ts +2 -0
  353. package/dist/tools/tools.js +56 -8
  354. package/dist/update/reticle-deps.d.ts +9 -0
  355. package/dist/update/reticle-deps.js +27 -0
  356. package/dist/update/sdk-sync.d.ts +1 -2
  357. package/dist/update/sdk-sync.js +1 -21
  358. package/dist/update/update-nudge.d.ts +18 -0
  359. package/dist/update/update-nudge.js +20 -0
  360. package/dist/version/peer-announce.js +4 -2
  361. package/dist/version/sdk-fix.d.ts +47 -0
  362. package/dist/version/sdk-fix.js +127 -0
  363. package/dist/version/version-nudge.d.ts +14 -0
  364. package/dist/version/version-nudge.js +21 -0
  365. package/dist/version/version-skew.d.ts +38 -3
  366. package/dist/version/version-skew.js +83 -7
  367. package/dist/visual/visual-store.d.ts +6 -6
  368. package/dist/visual/visual-store.js +18 -16
  369. package/dist/visual/visual-tools.js +57 -12
  370. package/docs/cli/doctor.mdx +1 -0
  371. package/docs/cli/serve.mdx +4 -2
  372. package/docs/cli.mdx +7 -3
  373. package/docs/desktop.mdx +1 -1
  374. package/docs/docs.json +1 -1
  375. package/docs/getting-started.md +19 -3
  376. package/docs/index.mdx +9 -6
  377. package/docs/install-agentic.mdx +38 -12
  378. package/docs/install-manual.mdx +1 -1
  379. package/docs/packages.mdx +1 -1
  380. package/docs/skill-file.mdx +3 -3
  381. package/docs/telemetry-contract.md +108 -4
  382. package/docs/tools-act-sequence.mdx +16 -0
  383. package/docs/usage.md +1 -1
  384. package/package.json +4 -3
  385. package/dist/init/cursor.d.ts +0 -24
  386. package/dist/init/cursor.js +0 -80
@@ -1,4 +1,4 @@
1
- import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, isSameDocument, isSameEditEpoch, } from '@reticlehq/core';
1
+ import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, isSameDocument, isThirdPartyUrl, isSameEditEpoch, urlForMatch, } from '@reticlehq/core';
2
2
  import { describeSuperseded } from './observed-in-window.js';
3
3
  import { findStaleResponses } from './stale-response.js';
4
4
  import { findBodyFailures } from './body-failures.js';
@@ -12,6 +12,7 @@ function netCall(e) {
12
12
  return {
13
13
  method: (asString(e.data['method']) ?? '').toUpperCase(),
14
14
  url: asString(e.data['url']) ?? '',
15
+ matchUrl: urlForMatch(e.data),
15
16
  status,
16
17
  // `ok` is authoritative when present (IPC sets it explicitly); status is the HTTP fallback.
17
18
  // Neither present = no verdict was ever reported, which stays undefined all the way through.
@@ -139,6 +140,45 @@ const NET_TYPES = new Set([
139
140
  EventType.NET_REQUEST,
140
141
  EventType.NET_STREAM,
141
142
  ]);
143
+ /**
144
+ * Hash-router paths put the route in the fragment (`#/settings`, `#!/home`). An in-page skip link
145
+ * does not (`#main-content`, `#`, empty). The blank-destination rule must still see the former.
146
+ */
147
+ function isInPageFragment(hash) {
148
+ if ('' === hash || '#' === hash)
149
+ return true;
150
+ const body = hash.startsWith('#') ? hash.slice(1) : hash;
151
+ return !body.startsWith('/') && !body.startsWith('!');
152
+ }
153
+ function hrefAsUrl(value) {
154
+ if (value === undefined || '' === value)
155
+ return undefined;
156
+ try {
157
+ return new URL(value);
158
+ }
159
+ catch {
160
+ return undefined;
161
+ }
162
+ }
163
+ /**
164
+ * Same origin + pathname + search, different in-page fragment — a skip link, not a new view.
165
+ *
166
+ * Returns false when `from`/`to` are missing, so older events without hrefs keep the existing rule.
167
+ */
168
+ function isSameDocumentHashAnchor(event) {
169
+ if (event.type !== EventType.ROUTE_CHANGE)
170
+ return false;
171
+ const from = hrefAsUrl(asString(event.data['from']));
172
+ const to = hrefAsUrl(asString(event.data['to']));
173
+ if (from === undefined || to === undefined)
174
+ return false;
175
+ if (from.origin !== to.origin || from.pathname !== to.pathname || from.search !== to.search) {
176
+ return false;
177
+ }
178
+ if (from.hash === to.hash)
179
+ return false;
180
+ return isInPageFragment(to.hash);
181
+ }
142
182
  /**
143
183
  * Split the window into the app's traffic and the dev toolchain's own (see `DevToolingChannel`).
144
184
  *
@@ -149,13 +189,15 @@ const NET_TYPES = new Set([
149
189
  * every action. The overlay's own 404s and duplicate fetches are the same story on other checks,
150
190
  * which is why the split happens ONCE here rather than in the one check that reported it.
151
191
  */
152
- function splitDevTooling(events) {
192
+ function splitForeignTraffic(events, appOrigin) {
153
193
  const ignored = [];
154
194
  const app = events.filter((e) => {
155
195
  if (!NET_TYPES.has(e.type))
156
196
  return true;
157
197
  const url = asString(e.data['url']);
158
- if (!isDevToolingUrl(url))
198
+ // Somebody else's code, twice over: the toolchain's own channel, and any site that is not the
199
+ // app under test. Neither can answer the question every rule below asks.
200
+ if (!isDevToolingUrl(url) && !isThirdPartyUrl(url, appOrigin))
159
201
  return true;
160
202
  if (url !== undefined && !ignored.includes(url))
161
203
  ignored.push(url);
@@ -199,19 +241,19 @@ export function findContradictions(allEvents, options = {}) {
199
241
  }
200
242
  function findWindowContradictions(allEvents, options) {
201
243
  const found = [];
202
- const { app: allApp, ignored: ignoredDevTooling } = splitDevTooling(allEvents);
244
+ const { app: allApp, ignored: ignoredForeign } = splitForeignTraffic(allEvents, options.appOrigin);
203
245
  // ── Evidence belonging to a document that has since been replaced ───────────────────────────
204
246
  // Scoped ONCE, here, for the same reason the dev-tooling split is: every rule below asks "what
205
247
  // else was in this window", and answering that with a dead page's traffic is a defect in all of
206
248
  // them rather than in whichever one reported it. Applied after the dev-tooling split so the count
207
249
  // reported below is the app's own evidence and not the toolchain's noise.
208
- const events = allApp.filter((e) => isSameDocument(e.documentId, options.currentDocumentId));
209
- const superseded = allApp.length - events.length;
250
+ const scoped = allApp.filter((e) => isSameDocument(e.documentId, options.currentDocumentId));
251
+ const superseded = allApp.length - scoped.length;
210
252
  // An empty window was always allowed to mean "nothing happened"; that reading is only unsafe once
211
253
  // supersession is what emptied it. Reported ALONE and before every rule below, because a window
212
254
  // with nothing left in it is exactly the shape `action-had-no-effect` fires on — so without this
213
255
  // the fix would have swapped a wrong citation for a wrong accusation.
214
- if (superseded > 0 && 0 === events.length) {
256
+ if (superseded > 0 && 0 === scoped.length) {
215
257
  return [
216
258
  {
217
259
  kind: ContradictionKind.EVIDENCE_SUPERSEDED,
@@ -221,6 +263,18 @@ function findWindowContradictions(allEvents, options) {
221
263
  },
222
264
  ];
223
265
  }
266
+ // ── Evidence that predates the action ───────────────────────────────────────────────────────
267
+ // The attribution floor, and it is scoped ONCE here for the same reason the two filters above are:
268
+ // a rule that reads "what else was in this window" and gets handed traffic from before the caller
269
+ // acted is not refining its answer, it is answering about somebody else's action. Three rules
270
+ // (`duplicate-request`, `signal-without-consequence`, `consequence-elsewhere`) each checked this
271
+ // for themselves and the other six swept the whole window — which is how an assert with no `since`
272
+ // came to be judged against everything that had ever happened in the tab.
273
+ //
274
+ // Undefined means nothing attributed the window to an action at all; the floor then does nothing,
275
+ // and the consequence rules below decline to speak instead. See `advanced`.
276
+ const floor = options.actionSince;
277
+ const events = floor === undefined ? scoped : scoped.filter((e) => e.t >= floor);
224
278
  const settled = events.filter((e) => e.type === EventType.NET_REQUEST).map(netCall);
225
279
  // ── Overlapping reads that settled out of order ─────────────────────────────────────────────
226
280
  // Independent of any action, so it runs on every window: the race is a property of the timeline.
@@ -253,7 +307,22 @@ function findWindowContradictions(allEvents, options) {
253
307
  // The failures NOBODY declared — see ContradictionOptions.expectedFailures. Only the heuristic
254
308
  // "the UI moved while a request failed" rule reads this; the sharp rules still read `failed`.
255
309
  const unexpected = failed.filter((c) => !matchesDeclaredFailure(c, options.expectedFailures ?? []));
256
- const advanced = uiAdvanced(events);
310
+ /**
311
+ * Did the UI move — and is anybody entitled to say so?
312
+ *
313
+ * `undefined` is the third answer and the point of the tri-state: "the UI moved forward while a
314
+ * request failed" is a claim about CAUSATION, and over a window nothing attributed to an action
315
+ * the two halves merely co-occurred. A passive `reticle_assert` performs nothing, so ambient
316
+ * traffic — a poll, a page-load bootstrap, a branding call — is not its consequence and must not
317
+ * decide its verdict.
318
+ *
319
+ * Typed rather than gated with a boolean so it cannot be skipped: every rule that reasons from UI
320
+ * movement has to answer `undefined` explicitly, including one added later. The rules that read
321
+ * the app's OWN claims (a success signal over a failed call) or its payloads (a 2xx whose body
322
+ * says it failed, a field echoed back wrong) are untouched — those are things the app said, not
323
+ * consequences anybody inferred, and they are true whoever caused them.
324
+ */
325
+ const advanced = floor === undefined ? undefined : uiAdvanced(events);
257
326
  const signals = events
258
327
  .filter((e) => e.type === EventType.SIGNAL)
259
328
  .map((e) => asString(e.data['name']) ?? 'signal');
@@ -261,7 +330,13 @@ function findWindowContradictions(allEvents, options) {
261
330
  // A navigation that neither fetches nor renders arrived nowhere. Distinct from a dead control:
262
331
  // the control worked, the DESTINATION is empty — which is why every "did the click do something"
263
332
  // heuristic passes it, a route change being unambiguously something.
264
- const routed = events.some((e) => e.type === EventType.ROUTE_CHANGE);
333
+ const routeEvents = events.filter((e) => e.type === EventType.ROUTE_CHANGE);
334
+ const routed = routeEvents.length > 0;
335
+ // A skip link (`href="#main-content"`) is a same-document hash change. The observable
336
+ // consequences are location.hash, focus, and scroll — not a DOM mutation. Treating it as a
337
+ // blank destination made "did my skip link work" unanswerable. Hash-router paths (`#/invoices`)
338
+ // still go through the rule: those ARE a new view.
339
+ const hashAnchorOnly = routed && routeEvents.every(isSameDocumentHashAnchor);
265
340
  // `dom.text` counts as rendered, and it has to: React reconciles a destination IN PLACE far more
266
341
  // often than it adds nodes. Measured on three ordinary sidebar navigations of the bench app — every
267
342
  // one emitted { dom.attr:2, dom.text:2, render.commit, state.change } and ZERO dom.added/removed,
@@ -276,7 +351,7 @@ function findWindowContradictions(allEvents, options) {
276
351
  e.type === EventType.DOM_REMOVED ||
277
352
  e.type === EventType.DOM_TEXT);
278
353
  const fetched = events.some((e) => e.type === EventType.NET_REQUEST || e.type === EventType.NET_PENDING);
279
- if (routed && !rendered && !fetched && true !== options.renderProved) {
354
+ if (routed && !hashAnchorOnly && !rendered && !fetched && true !== options.renderProved) {
280
355
  found.push({
281
356
  kind: ContradictionKind.ROUTE_RENDERED_NOTHING,
282
357
  claim: 'the app navigated to a new route',
@@ -324,26 +399,48 @@ function findWindowContradictions(allEvents, options) {
324
399
  // Ordering cannot decide this: an optimistic UI legitimately fires its success signal BEFORE the
325
400
  // response, so "the claim must follow the failure" would miss the real defect. What separates them
326
401
  // is not when the app spoke, it is whether it took it back.
327
- if (failed.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
402
+ // WHAT failed decides this, not whether an action was attributed.
403
+ //
404
+ // The claim is a thing the app said, so the attribution floor does not apply to it — the flagship
405
+ // false green is an app asserting success on a PASSIVE assert while its own write failed, and
406
+ // requiring an action would lose exactly that. But the COUNTER was any failure in the window, and
407
+ // that is the same window statement scoped out of every other rule here. Measured: two of the
408
+ // three false positives surviving the first scoping pass were this rule.
409
+ //
410
+ // A success signal claims a CHANGE was made. A failed mutation is evidence against that claim; a
411
+ // failed read is not. Background polls, prefetches and telemetry GETs fail constantly in healthy
412
+ // apps and say nothing about whether a write landed. Structural, not a timing heuristic, and the
413
+ // distinction was already encoded next door in `isMutating`.
414
+ const failedWrites = failed.filter(isMutating);
415
+ // The same scoping, for the same reason, one branch down. "The UI moved forward" is also a claim
416
+ // that something CHANGED, and it was still reading ANY failure — so a first-party poll failing
417
+ // during an action contradicted a verdict the action had genuinely earned. Measured on the
418
+ // observation benchmark: one of two false positives across 47 cells, and the argument for it is
419
+ // the paragraph above, which had simply not been carried down here.
420
+ const unexpectedWrites = unexpected.filter(isMutating);
421
+ if (failedWrites.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
328
422
  found.push({
329
423
  kind: ContradictionKind.SIGNAL_CONTRADICTED,
330
424
  claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
331
- counter: `${String(failed.length)} request(s) in the same window failed`,
332
- detail: failed.map(describe).join('; '),
425
+ counter: `${String(failedWrites.length)} write(s) in the same window failed`,
426
+ detail: failedWrites.map(describe).join('; '),
333
427
  });
334
428
  }
335
- else if (unexpected.length > 0 && advanced && !misattributed && !failureAcknowledged(events)) {
429
+ else if (unexpectedWrites.length > 0 &&
430
+ true === advanced &&
431
+ !misattributed &&
432
+ !failureAcknowledged(events)) {
336
433
  found.push({
337
434
  kind: ContradictionKind.UI_ADVANCED_REQUEST_FAILED,
338
435
  claim: 'the UI moved forward (DOM/store/route changed)',
339
- counter: `${String(unexpected.length)} request(s) in the same window failed`,
340
- detail: unexpected.map(describe).join('; '),
436
+ counter: `${String(unexpectedWrites.length)} request(s) in the same window failed`,
437
+ detail: unexpectedWrites.map(describe).join('; '),
341
438
  });
342
439
  }
343
440
  // ── A write succeeded and nothing on the client moved ───────────────────────────────────────
344
441
  // Writes only: a GET that changes nothing is a prefetch; a POST that changes nothing is a lost
345
442
  // write, a response parsed into the void, or a render that never happened.
346
- if (!advanced) {
443
+ if (false === advanced) {
347
444
  const ignoredWrites = settled.filter((c) => true === c.ok && isMutating(c));
348
445
  if (ignoredWrites.length > 0) {
349
446
  // ...unless THIS document handed the consequence to another browsing context. An OAuth sign-in
@@ -387,7 +484,7 @@ function findWindowContradictions(allEvents, options) {
387
484
  // existing tests that assert exactly that, which is the false-positive class this scoping exists
388
485
  // to prevent.
389
486
  if (options.actionSince !== undefined &&
390
- !advanced &&
487
+ false === advanced &&
391
488
  0 === settled.length &&
392
489
  successSignals.length > 0) {
393
490
  found.push({
@@ -413,10 +510,26 @@ function findWindowContradictions(allEvents, options) {
413
510
  // compares nothing rather than guess.
414
511
  const actionSince = options.actionSince;
415
512
  if (actionSince !== undefined) {
513
+ /**
514
+ * When the window navigated, and therefore when a write stops belonging to the user's action.
515
+ *
516
+ * React StrictMode double-invokes a mount effect in development, so clicking a nav link lands
517
+ * two identical writes inside the action's own window. Nothing scoped them out and they read as
518
+ * a double submit — measured on the observation benchmark as one of two false positives.
519
+ *
520
+ * The route change is the structural tell, not a heuristic: the claim this rule makes is "one
521
+ * user action was performed", and writes that follow a navigation belong to the mount of the
522
+ * view navigated TO. A real double submit fires from the view it is already on, with nothing in
523
+ * between — and one that navigates AFTER submitting is still counted, because the order is what
524
+ * distinguishes them.
525
+ */
526
+ const navigatedAt = events.find((e) => EventType.ROUTE_CHANGE === e.type && e.t >= actionSince)?.t;
416
527
  const writeCounts = new Map();
417
528
  for (const event of events) {
418
529
  if (event.type !== EventType.NET_REQUEST || event.t < actionSince)
419
530
  continue;
531
+ if (navigatedAt !== undefined && event.t >= navigatedAt)
532
+ continue;
420
533
  const call = netCall(event);
421
534
  if (!isMutating(call))
422
535
  continue;
@@ -442,7 +555,7 @@ function findWindowContradictions(allEvents, options) {
442
555
  // Gated on the UI having moved: an in-flight request while the app is still visibly waiting is
443
556
  // just a slow request, not a contradiction. It becomes one when the app proceeded regardless —
444
557
  // which is also what makes a later `{ kind: "settled" }` assertion a false green.
445
- if (advanced) {
558
+ if (true === advanced) {
446
559
  const settledIds = new Set(events
447
560
  .filter((e) => e.type === EventType.NET_REQUEST)
448
561
  .map((e) => asString(e.data['id']))
@@ -460,9 +573,9 @@ function findWindowContradictions(allEvents, options) {
460
573
  // the finding says which URLs, so an agent reading it can see what Reticle chose to ignore.
461
574
  detail: [
462
575
  inFlight.map((p) => describe(p.call)).join('; '),
463
- ...(0 === ignoredDevTooling.length
576
+ ...(0 === ignoredForeign.length
464
577
  ? []
465
- : [`ignored as dev tooling: ${ignoredDevTooling.join(', ')}`]),
578
+ : [`ignored as dev tooling or third-party: ${ignoredForeign.join(', ')}`]),
466
579
  ].join(' — '),
467
580
  });
468
581
  }
@@ -36,13 +36,15 @@ export declare function declaredExpectations(predicate: Predicate | undefined):
36
36
  *
37
37
  * The unread-body clause exists for the case where the body is the ONLY channel that could have
38
38
  * contradicted the screen — a 200 with per-item failures inside, a GraphQL error that is also a
39
- * 200. A declared string on screen, store path, or signal that held is a different channel, and
40
- * grading `unknown` there costs a real verdict (measured: a 201 plus the unique row, unread body,
41
- * agent went to enable capture instead of finishing the drive).
39
+ * 200. A declared string on screen, store path, signal, route, or an element located by role /
40
+ * name / testid that held is a different channel, and grading `unknown` there costs a real
41
+ * verdict (measured: a 201 plus the unique row, unread body, agent went to enable capture instead
42
+ * of finishing the drive).
42
43
  *
43
44
  * Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
44
45
  * may never have held, and honouring it would skip the unread caveat on the strength of a net
45
- * success that was the branch that actually matched.
46
+ * success that was the branch that actually matched. An `absent` element is not a consequence the
47
+ * body cannot own.
46
48
  */
47
49
  export declare function declaresBodyIndependentChannel(predicate: Predicate | undefined): boolean;
48
50
  /**
@@ -55,5 +57,6 @@ export declare function declaresBodyIndependentChannel(predicate: Predicate | un
55
57
  export declare function matchesDeclaredFailure(call: {
56
58
  method: string;
57
59
  url: string;
60
+ matchUrl?: string;
58
61
  status: number | undefined;
59
62
  }, declared: readonly DeclaredNetFailure[]): boolean;
@@ -58,13 +58,15 @@ export function declaredExpectations(predicate) {
58
58
  *
59
59
  * The unread-body clause exists for the case where the body is the ONLY channel that could have
60
60
  * contradicted the screen — a 200 with per-item failures inside, a GraphQL error that is also a
61
- * 200. A declared string on screen, store path, or signal that held is a different channel, and
62
- * grading `unknown` there costs a real verdict (measured: a 201 plus the unique row, unread body,
63
- * agent went to enable capture instead of finishing the drive).
61
+ * 200. A declared string on screen, store path, signal, route, or an element located by role /
62
+ * name / testid that held is a different channel, and grading `unknown` there costs a real
63
+ * verdict (measured: a 201 plus the unique row, unread body, agent went to enable capture instead
64
+ * of finishing the drive).
64
65
  *
65
66
  * Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
66
67
  * may never have held, and honouring it would skip the unread caveat on the strength of a net
67
- * success that was the branch that actually matched.
68
+ * success that was the branch that actually matched. An `absent` element is not a consequence the
69
+ * body cannot own.
68
70
  */
69
71
  export function declaresBodyIndependentChannel(predicate) {
70
72
  if (predicate === undefined)
@@ -77,9 +79,15 @@ export function declaresBodyIndependentChannel(predicate) {
77
79
  return true !== p.absent;
78
80
  case PredicateKind.SIGNAL:
79
81
  case PredicateKind.STATE:
82
+ case PredicateKind.ROUTE:
80
83
  return true;
81
84
  case PredicateKind.ELEMENT:
82
- return true !== p.absent && (p.query.value !== undefined || p.query.text !== undefined);
85
+ return (true !== p.absent &&
86
+ (p.query.value !== undefined ||
87
+ p.query.text !== undefined ||
88
+ p.query.role !== undefined ||
89
+ p.query.name !== undefined ||
90
+ p.query.testid !== undefined));
83
91
  default:
84
92
  return false;
85
93
  }
@@ -97,7 +105,8 @@ export function matchesDeclaredFailure(call, declared) {
97
105
  return declared.some((d) => {
98
106
  if (d.method !== undefined && d.method.toUpperCase() !== call.method.toUpperCase())
99
107
  return false;
100
- if (d.urlContains !== undefined && !call.url.includes(d.urlContains))
108
+ const haystack = call.matchUrl ?? call.url;
109
+ if (d.urlContains !== undefined && !haystack.includes(d.urlContains))
101
110
  return false;
102
111
  if (d.status !== undefined && d.status !== call.status)
103
112
  return false;
@@ -28,7 +28,15 @@ import { ContradictionKind, EventType, MUTATING_METHODS } from '@reticlehq/core'
28
28
  * - values are compared NORMALISED (trimmed, case-folded, numbers as numbers), so `FR` vs `fr` and
29
29
  * `1` vs `1.0` stay silent while `fr` vs `en` speaks;
30
30
  * - if the key appears anywhere in the response carrying the requested value, it is treated as
31
- * applied — an envelope that echoes both the old and the new value is not a dropped write.
31
+ * applied — an envelope that echoes both the old and the new value is not a dropped write;
32
+ * - the response must look like a restatement of the request before any key is compared. Reported
33
+ * from the field: a command bus POSTed `{command:'chat.send', ...}` and the server answered with
34
+ * the current viewer snapshot. The message arrived over the socket and rendered. `id` in the
35
+ * snapshot meant the viewer, `id` in the request meant the message, and this kind fired. A
36
+ * snapshot that shares a key name is not an echo. A discriminator the request carries that the
37
+ * response does not, or a fat body that shares one coincidental key, is skipped. The existing
38
+ * single-value / not-echoed-at-all guards still trade false negatives away — this narrows on
39
+ * shape, not on value types.
32
40
  *
33
41
  * The residue after those filters is a field the caller set, the server echoed back, and the echoed
34
42
  * value is a genuinely different value. That is worth one line of an agent's attention.
@@ -94,6 +102,60 @@ function scalarsByKey(body, into = new Map(), depth = 0) {
94
102
  }
95
103
  const OK_MIN = 200;
96
104
  const OK_MAX = 300;
105
+ /**
106
+ * Body keys that name the operation rather than a field being persisted. A command bus puts one of
107
+ * these on the request; a snapshot of the current viewer does not echo it. Their absence on the
108
+ * response is the cheapest honest signal that the body is not a restatement of the write.
109
+ */
110
+ const EchoDiscriminator = {
111
+ COMMAND: 'command',
112
+ ACTION: 'action',
113
+ OP: 'op',
114
+ PROCEDURE: 'procedure',
115
+ };
116
+ /**
117
+ * A response carrying this many times more scalar keys than the request, overlapping on fewer than
118
+ * `MIN_SHARED_KEYS`, is a snapshot that happened to reuse a name — not an echo of the write.
119
+ */
120
+ const SNAPSHOT_KEY_RATIO = 3;
121
+ const MIN_SHARED_KEYS = 2;
122
+ /**
123
+ * True when the response looks like a restatement of the request, so overlapping key names can be
124
+ * read as an echo rather than a coincidence.
125
+ *
126
+ * Half the request's comparable keys must appear in the response. That keeps a partial echo
127
+ * (`{density, locale}` answered with only `locale`) in scope, and drops a three-field command whose
128
+ * snapshot shares one name. A request that names an operation the response does not repeat is not
129
+ * an echo at all, regardless of any other overlap.
130
+ */
131
+ function responseRestatesRequest(asked, echoed) {
132
+ const comparable = [];
133
+ for (const [key, wanted] of asked) {
134
+ if (1 === wanted.size)
135
+ comparable.push(key);
136
+ }
137
+ if (0 === comparable.length)
138
+ return false;
139
+ let present = 0;
140
+ for (const key of comparable) {
141
+ if (echoed.has(key))
142
+ present += 1;
143
+ }
144
+ let requestHasDiscriminator = false;
145
+ let responseHasDiscriminator = false;
146
+ for (const key of Object.values(EchoDiscriminator)) {
147
+ if (asked.has(key))
148
+ requestHasDiscriminator = true;
149
+ if (echoed.has(key))
150
+ responseHasDiscriminator = true;
151
+ }
152
+ if (requestHasDiscriminator && !responseHasDiscriminator)
153
+ return false;
154
+ if (echoed.size > comparable.length * SNAPSHOT_KEY_RATIO && present < MIN_SHARED_KEYS) {
155
+ return false;
156
+ }
157
+ return present * 2 >= comparable.length;
158
+ }
97
159
  /**
98
160
  * Contradictions for writes whose response echoes a different value than the request asked for.
99
161
  *
@@ -133,19 +195,7 @@ export function findEchoMismatches(events, actionSince) {
133
195
  continue;
134
196
  const echoed = scalarsByKey(response);
135
197
  const asked = scalarsByKey(request);
136
- // The response has to be ECHO-SHAPED before any key can contradict anything. An echo restates
137
- // what was sent, so most of the request's scalar keys reappear somewhere in it; a snapshot that
138
- // happens to share a key or two does not (#506: `{"command":"chat.send"}` answered with the
139
- // viewer snapshot read one incidental shared key as an attempted echo of a write that fully
140
- // applied). Requiring a strict majority of the request's keys to appear keeps every documented
141
- // true positive — an envelope restates most of its fields — and deliberately trades the rare
142
- // half-echoed write away, the same direction MAX_ECHO_KEYS already trades in.
143
- let shared = 0;
144
- for (const key of asked.keys()) {
145
- if (echoed.has(key))
146
- shared += 1;
147
- }
148
- if (shared * 2 <= asked.size)
198
+ if (!responseRestatesRequest(asked, echoed))
149
199
  continue;
150
200
  const dropped = [];
151
201
  for (const [key, wanted] of asked) {
@@ -32,6 +32,14 @@ export declare function matchNet(e: ReticleEvent, method: string | undefined, ur
32
32
  * pending: true }` so a numeric `status` filter excludes them but `urlContains`/`method` match.
33
33
  */
34
34
  export declare function reconcileNet(events: ReticleEvent[]): ReticleEvent[];
35
+ /**
36
+ * Strip the grader-only raw URL before an event is rendered to the agent.
37
+ *
38
+ * `urlRaw` exists so `urlContains` can match a path redaction rewrote. It is a secret-bearing field
39
+ * by construction (the whole reason `url` was redacted) and must never appear in observe, evidence,
40
+ * or a network listing.
41
+ */
42
+ export declare function withoutUrlRaw(event: unknown): unknown;
35
43
  /** Compact network-call summary for reticle_network output — drops event plumbing (t, type,
36
44
  * sessionId, ref, id, initiator, ok) the agent never needs, keeping only method/url/status/ms.
37
45
  * This is the bulk of the token cost: raw ReticleEvent objects are ~5x larger than this. */
@@ -1,4 +1,4 @@
1
- import { EventType } from '@reticlehq/core';
1
+ import { EventType, URL_RAW, urlForMatch } from '@reticlehq/core';
2
2
  import { asString, asNumber } from '../tools/tools-helpers.js';
3
3
  import { mergeNetworkDetail } from '../input/network-detail.js';
4
4
  /**
@@ -55,7 +55,7 @@ export function matchNet(e, method, urlContains, status, ok) {
55
55
  if (method !== undefined && asString(d['method'])?.toUpperCase() !== method.toUpperCase()) {
56
56
  return false;
57
57
  }
58
- if (urlContains !== undefined && !(asString(d['url']) ?? '').includes(urlContains)) {
58
+ if (urlContains !== undefined && !urlForMatch(d).includes(urlContains)) {
59
59
  return false;
60
60
  }
61
61
  if (status !== undefined && asNumber(d['status']) !== status)
@@ -86,6 +86,23 @@ export function reconcileNet(events) {
86
86
  .map((e) => ({ ...e, data: { ...e.data, status: 'pending', pending: true } }));
87
87
  return [...completed, ...unresolved].sort((a, b) => a.t - b.t);
88
88
  }
89
+ /**
90
+ * Strip the grader-only raw URL before an event is rendered to the agent.
91
+ *
92
+ * `urlRaw` exists so `urlContains` can match a path redaction rewrote. It is a secret-bearing field
93
+ * by construction (the whole reason `url` was redacted) and must never appear in observe, evidence,
94
+ * or a network listing.
95
+ */
96
+ export function withoutUrlRaw(event) {
97
+ if (typeof event !== 'object' || null === event)
98
+ return event;
99
+ const rec = event;
100
+ const data = rec['data'];
101
+ if (typeof data !== 'object' || null === data || !(URL_RAW in data))
102
+ return event;
103
+ const { [URL_RAW]: _raw, ...rest } = data;
104
+ return { ...rec, data: rest };
105
+ }
89
106
  export function projectNetCall(e, includeBodies = true) {
90
107
  const status = e.data['status'];
91
108
  const ms = asNumber(e.data['durationMs']);
@@ -1,5 +1,6 @@
1
- import { EventType, PredicateKind, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
1
+ import { EventType, PredicateKind, StreamDirection, isDevToolingUrl, urlForMatch, REDACTED_VALUE, } from '@reticlehq/core';
2
2
  import { describeObserved } from './observed-in-window.js';
3
+ import { withoutUrlRaw } from './event-filters.js';
3
4
  // The predicate SHAPE — the discriminated union, its aliases and its zod schema — lives in
4
5
  // predicate-schema.ts. Re-exported here so every existing importer of this module is unaffected:
5
6
  // the two halves are one public surface, split only because the file outgrew the size backstop.
@@ -140,6 +141,26 @@ function describeCall(e) {
140
141
  const status = num(e.data['status']);
141
142
  return status === undefined ? head : `${head} → ${String(status)}`;
142
143
  }
144
+ /**
145
+ * What a miss should say when the displayed URL was redacted. Matching uses `urlRaw` when present;
146
+ * an older SDK has no copy, and this is the sentence both reporters asked for.
147
+ */
148
+ const REDACTED_PATH_HINT = 'this path segment was redacted — the literal you matched may be here, try bodyContains';
149
+ function observedNetCalls(events, urlContains) {
150
+ const calls = events.filter((e) => e.type === EventType.NET_REQUEST);
151
+ const base = describeObserved('calls', calls.map(describeCall));
152
+ if (urlContains === undefined)
153
+ return base;
154
+ const encoded = encodeURIComponent(REDACTED_VALUE);
155
+ const redacted = calls.some((e) => {
156
+ const url = str(e.data['url']) ?? '';
157
+ return url.includes(REDACTED_VALUE) || url.includes(encoded);
158
+ });
159
+ return redacted ? `${base}; ${REDACTED_PATH_HINT}` : base;
160
+ }
161
+ function netEvidence(data) {
162
+ return withoutUrlRaw({ data }).data;
163
+ }
143
164
  /**
144
165
  * How much of a response body a failure may quote. Enough to see the value that differed on the
145
166
  * bodies this field is used against (a JSON answer), and not a whole payload in every verdict.
@@ -314,6 +335,12 @@ export function evalNet(events, p) {
314
335
  * a UI wiring bug that did not exist, when the defect was the value the server answered with.
315
336
  */
316
337
  let bodyMismatch;
338
+ /**
339
+ * The prefix of a TRUNCATED body the needle was not found in. Held apart from `bodyMismatch`
340
+ * because the two are different verdicts: a full body without the needle decides the assertion,
341
+ * a truncated one cannot (#614).
342
+ */
343
+ let truncatedBody;
317
344
  const matches = events.filter((e) => {
318
345
  if (e.type !== EventType.NET_REQUEST || e.t < since)
319
346
  return false;
@@ -321,7 +348,7 @@ export function evalNet(events, p) {
321
348
  if (p.method !== undefined && str(d['method'])?.toUpperCase() !== p.method.toUpperCase()) {
322
349
  return false;
323
350
  }
324
- if (p.urlContains !== undefined && !(str(d['url']) ?? '').includes(p.urlContains)) {
351
+ if (p.urlContains !== undefined && !urlForMatch(d).includes(p.urlContains)) {
325
352
  return false;
326
353
  }
327
354
  if (p.status !== undefined) {
@@ -350,6 +377,15 @@ export function evalNet(events, p) {
350
377
  return false;
351
378
  }
352
379
  if (!response.includes(p.bodyContains)) {
380
+ // A needle missing from a body we only hold the FIRST N BYTES of is undecidable, not
381
+ // absent: the rest of the response was never recorded, so nothing here can say whether it
382
+ // was in there (#614). Grading it `pass: false` with "the response value is what differed"
383
+ // is the inversion the honesty rules exist to prevent — an unknown reported as decided,
384
+ // against a response that was very likely correct.
385
+ if (true === d['responseBodyTruncated']) {
386
+ truncatedBody ??= response;
387
+ return false;
388
+ }
353
389
  bodyMismatch ??= response;
354
390
  return false;
355
391
  }
@@ -377,6 +413,18 @@ export function evalNet(events, p) {
377
413
  assertion: 'net.bodyContains',
378
414
  };
379
415
  }
416
+ // Ranked ABOVE the mismatch branch: when both a truncated and a full body missed the needle,
417
+ // the honest verdict is the undecidable one. Deciding on the full body would report a failure
418
+ // the truncated call may well contradict.
419
+ if (truncatedBody !== undefined && 0 === matches.length) {
420
+ return {
421
+ pass: false,
422
+ inconclusive: `a call matching ${describeNetFilter(p)} was answered with a body that was TRUNCATED before it was recorded, and ${JSON.stringify(p.bodyContains)} is not in the part that was kept — so this is undecidable, not a failure. Raise the capture cap or assert on something inside the recorded prefix`,
423
+ observed: `the first ${String(truncatedBody.length)} characters of a truncated response body ${JSON.stringify(clipBody(truncatedBody))}`,
424
+ expected: `a response body containing ${JSON.stringify(p.bodyContains)}`,
425
+ assertion: 'net.bodyContains',
426
+ };
427
+ }
380
428
  if (bodyMismatch !== undefined && 0 === matches.length) {
381
429
  // The call is there and its body is there; only the VALUE differs. Counting it as zero matches
382
430
  // points at the wiring, which is the one place the defect is not.
@@ -418,19 +466,19 @@ export function evalNet(events, p) {
418
466
  pass: false,
419
467
  failureReason: reason,
420
468
  inconclusive: reason,
421
- observed: describeObserved('calls', events.filter((e) => e.type === EventType.NET_REQUEST).map(describeCall)),
469
+ observed: observedNetCalls(events, p.urlContains),
422
470
  expected: `at least one call matching ${describeNetFilter(p)}`,
423
471
  assertion: 'net.unobserved-channel',
424
472
  };
425
473
  }
426
474
  return hit !== undefined
427
- ? { pass: true, evidence: hit.data }
475
+ ? { pass: true, evidence: netEvidence(hit.data) }
428
476
  : {
429
477
  pass: false,
430
478
  failureReason: `no network call matched ${JSON.stringify(p)}`,
431
479
  // Same reasoning as the signal miss: "no matching call" cannot be told apart from "the app
432
480
  // made no calls at all", and those need different fixes.
433
- observed: describeObserved('calls', events.filter((e) => e.type === EventType.NET_REQUEST).map(describeCall)),
481
+ observed: observedNetCalls(events, p.urlContains),
434
482
  expected: `at least one call matching ${JSON.stringify(p)}`,
435
483
  assertion: 'net.present',
436
484
  };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The reason this predicate cannot be evaluated, or undefined when it can.
3
+ *
4
+ * Only checks what is knowable WITHOUT the page — a locator that names fields the resolver drops is
5
+ * wrong whatever the DOM contains, which is what makes it safe to refuse before acting. Anything
6
+ * that depends on what is actually rendered is still decided after the action, where it belongs.
7
+ */
8
+ export declare function unevaluablePredicateReason(predicate: unknown): string | undefined;